Merge remote-tracking branch 'origin/master' into interpolated_options

Conflicts:
app/models/agent.rb
spec/models/agent_spec.rb

Andrew Cantino 10 years ago
parent
commit
fbcede8649
73 changed files with 2717 additions and 758 deletions
  1. 11 5
      app/assets/javascripts/application.js.coffee.erb
  2. 18 2
      app/assets/stylesheets/application.css.scss.erb
  3. 15 0
      app/assets/stylesheets/scenarios.css.scss
  4. 1 1
      lib/assignable_types.rb
  5. 13 0
      app/concerns/has_guid.rb
  6. 0 0
      app/concerns/inheritance_tracking.rb
  7. 0 0
      app/concerns/json_serialized_field.rb
  8. 0 0
      app/concerns/markdown_class_attributes.rb
  9. 53 21
      app/controllers/agents_controller.rb
  10. 20 0
      app/controllers/scenario_imports_controller.rb
  11. 100 0
      app/controllers/scenarios_controller.rb
  12. 6 0
      app/helpers/agent_helper.rb
  13. 1 0
      app/helpers/dot_helper.rb
  14. 9 1
      app/models/agent.rb
  15. 6 7
      app/models/agents/pushover_agent.rb
  16. 19 0
      app/models/scenario.rb
  17. 256 0
      app/models/scenario_import.rb
  18. 4 0
      app/models/scenario_membership.rb
  19. 1 0
      app/models/user.rb
  20. 12 2
      app/views/agents/_action_menu.html.erb
  21. 14 2
      app/views/agents/_form.html.erb
  22. 75 0
      app/views/agents/_table.html.erb
  23. 1 1
      app/views/agents/agent_views/manual_event_agent/_show.html.erb
  24. 1 1
      app/views/agents/diagram.html.erb
  25. 1 70
      app/views/agents/index.html.erb
  26. 1 1
      app/views/agents/show.html.erb
  27. 2 2
      app/views/events/index.html.erb
  28. 1 0
      app/views/layouts/_navigation.html.erb
  29. 14 10
      app/views/layouts/application.html.erb
  30. 31 0
      app/views/scenario_imports/_step_one.html.erb
  31. 154 0
      app/views/scenario_imports/_step_two.html.erb
  32. 32 0
      app/views/scenario_imports/new.html.erb
  33. 57 0
      app/views/scenarios/_form.html.erb
  34. 21 0
      app/views/scenarios/edit.html.erb
  35. 50 0
      app/views/scenarios/index.html.erb
  36. 21 0
      app/views/scenarios/new.html.erb
  37. 33 0
      app/views/scenarios/share.html.erb
  38. 28 0
      app/views/scenarios/show.html.erb
  39. 12 0
      config/routes.rb
  40. 12 0
      db/migrate/20140509170420_create_scenarios.rb
  41. 10 0
      db/migrate/20140509170443_create_scenario_memberships.rb
  42. 8 0
      db/migrate/20140531232016_add_fields_to_scenarios.rb
  43. 7 0
      db/migrate/20140602014917_add_indices_to_scenarios.rb
  44. 15 0
      db/migrate/20140605032822_add_guid_to_agents.rb
  45. 90 64
      db/schema.rb
  46. 54 0
      lib/agents_exporter.rb
  47. 0 0
      spec/concerns/inheritance_tracking_spec.rb
  48. 103 0
      spec/controllers/agents_controller_spec.rb
  49. 26 0
      spec/controllers/scenario_imports_controller_spec.rb
  50. 152 0
      spec/controllers/scenarios_controller_spec.rb
  51. 8 0
      spec/fixtures/agents.yml
  52. 15 0
      spec/fixtures/scenario_memberships.yml
  53. 13 0
      spec/fixtures/scenarios.yml
  54. 3 1
      spec/fixtures/users.yml
  55. 61 0
      spec/lib/agents_exporter_spec.rb
  56. 24 1
      spec/models/agent_spec.rb
  57. 0 1
      spec/models/agents/data_output_agent_spec.rb
  58. 0 1
      spec/models/agents/hipchat_agent_spec.rb
  59. 0 1
      spec/models/agents/human_task_agent_spec.rb
  60. 0 1
      spec/models/agents/jabber_agent_spec.rb
  61. 0 1
      spec/models/agents/peak_detector_agent_spec.rb
  62. 0 1
      spec/models/agents/pushbullet_agent_spec.rb
  63. 2 2
      spec/models/agents/slack_agent_spec.rb
  64. 0 2
      spec/models/agents/translation_agent_spec.rb
  65. 0 1
      spec/models/agents/trigger_agent_spec.rb
  66. 411 0
      spec/models/scenario_import_spec.rb
  67. 43 0
      spec/models/scenario_spec.rb
  68. 12 0
      spec/support/shared_examples/has_guid.rb
  69. 0 0
      spec/support/shared_examples/liquid_interpolatable.rb
  70. 3 3
      spec/models/concerns/working_helpers.rb
  71. 514 489
      vendor/assets/javascripts/jquery.json-editor.js
  72. 37 0
      vendor/assets/stylesheets/jquery.json-editor.css
  73. 0 63
      vendor/assets/stylesheets/jquery.json-editor.css.scss

+ 11 - 5
app/assets/javascripts/application.js.coffee.erb

@@ -9,14 +9,17 @@
9 9
 #= require ./worker-checker
10 10
 #= require_self
11 11
 
12
-window.setupJsonEditor = ($editor = $(".live-json-editor")) ->
12
+window.setupJsonEditor = ($editors = $(".live-json-editor")) ->
13 13
   JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>'
14 14
   JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>'
15
-  if $editor.length
15
+  editors = []
16
+  $editors.each ->
17
+    $editor = $(this)
16 18
     jsonEditor = new JSONEditor($editor, $editor.data('width') || 400, $editor.data('height') || 500)
17 19
     jsonEditor.doTruncation true
18 20
     jsonEditor.showFunctionButtons()
19
-    return jsonEditor
21
+    editors.push jsonEditor
22
+  return editors
20 23
 
21 24
 hideSchedule = ->
22 25
   $(".schedule-region select").hide()
@@ -55,12 +58,15 @@ showEventDescriptions = ->
55 58
 
56 59
 $(document).ready ->
57 60
   # JSON Editor
58
-  window.jsonEditor = setupJsonEditor()
61
+  window.jsonEditor = setupJsonEditor()[0]
59 62
 
60 63
   # Flash
61 64
   if $(".flash").length
62 65
     setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
63 66
 
67
+  # Help popovers
68
+  $('.hover-help').popover(trigger: 'hover')
69
+
64 70
   # Agent Navigation
65 71
   $agentNavigate = $('#agent-navigate')
66 72
 
@@ -99,7 +105,7 @@ $(document).ready ->
99 105
         e.preventDefault()
100 106
         $agentNavigate.focus()
101 107
 
102
-# Agent Show
108
+  # Agent Show
103 109
   fetchLogs = (e) ->
104 110
     agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
105 111
     e.preventDefault()

+ 18 - 2
app/assets/stylesheets/application.css.scss.erb

@@ -140,13 +140,29 @@ span.not-applicable:after {
140 140
   opacity: 0.5;
141 141
 }
142 142
 
143
-// Fix JSON Editor
143
+// JSON Editor
144
+
145
+.live-json-editor {
146
+  font-family: "Courier New", Courier, monospace;
147
+}
144 148
 
145 149
 .json-editor blockquote {
146 150
   font-size: 14px;
147 151
 }
148 152
 
149
-// Bootstrappy colour styles
153
+// Position tweeks
154
+
155
+.hover-help {
156
+  top: 2px;
157
+}
158
+
159
+h2 .scenario, a span.label.scenario {
160
+  position: relative;
161
+  top: -2px;
162
+}
163
+
164
+// Bootstrappy color styles
165
+
150 166
 .color-danger {
151 167
   color: #d9534f;
152 168
 }

+ 15 - 0
app/assets/stylesheets/scenarios.css.scss

@@ -0,0 +1,15 @@
1
+.scenario-import {
2
+  .agent-import-list {
3
+    .agent-import {
4
+      margin-bottom: 20px;
5
+
6
+      .instructions {
7
+        margin-bottom: 10px;
8
+      }
9
+
10
+      .current {
11
+        font-weight: bold;
12
+      }
13
+    }
14
+  }
15
+}

+ 1 - 1
lib/assignable_types.rb

@@ -29,7 +29,7 @@ module AssignableTypes
29 29
       const_get(:TYPES).include?(type)
30 30
     end
31 31
 
32
-    def build_for_type(type, user, attributes)
32
+    def build_for_type(type, user, attributes = {})
33 33
       attributes.delete(:type)
34 34
 
35 35
       if valid_type?(type)

+ 13 - 0
app/concerns/has_guid.rb

@@ -0,0 +1,13 @@
1
+module HasGuid
2
+  extend ActiveSupport::Concern
3
+
4
+  included do
5
+    before_save :make_guid
6
+  end
7
+
8
+  protected
9
+
10
+  def make_guid
11
+    self.guid = SecureRandom.hex unless guid.present?
12
+  end
13
+end

lib/inheritance_tracking.rb → app/concerns/inheritance_tracking.rb


lib/json_serialized_field.rb → app/concerns/json_serialized_field.rb


lib/markdown_class_attributes.rb → app/concerns/markdown_class_attributes.rb


+ 53 - 21
app/controllers/agents_controller.rb

@@ -21,12 +21,12 @@ class AgentsController < ApplicationController
21 21
   end
22 22
 
23 23
   def run
24
-    agent = current_user.agents.find(params[:id])
25
-    Agent.async_check(agent.id)
26
-    if params[:return] == "show"
27
-      redirect_to agent_path(agent), notice: "Agent run queued"
28
-    else
29
-      redirect_to agents_path, notice: "Agent run queued"
24
+    @agent = current_user.agents.find(params[:id])
25
+    Agent.async_check(@agent.id)
26
+
27
+    respond_to do |format|
28
+      format.html { redirect_back "Agent run queued for '#{@agent.name}'" }
29
+      format.json { head :ok }
30 30
     end
31 31
   end
32 32
 
@@ -53,12 +53,20 @@ class AgentsController < ApplicationController
53 53
   def remove_events
54 54
     @agent = current_user.agents.find(params[:id])
55 55
     @agent.events.delete_all
56
-    redirect_to agents_path, notice: "All events removed"
56
+
57
+    respond_to do |format|
58
+      format.html { redirect_back "All emitted events removed for '#{@agent.name}'" }
59
+      format.json { head :ok }
60
+    end
57 61
   end
58 62
 
59 63
   def propagate
60
-    details = Agent.receive!
61
-    redirect_to agents_path, notice: "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)"
64
+    details = Agent.receive! # Eventually this should probably be scoped to the current_user.
65
+
66
+    respond_to do |format|
67
+      format.html { redirect_back "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)" }
68
+      format.json { head :ok }
69
+    end
62 70
   end
63 71
 
64 72
   def show
@@ -90,7 +98,11 @@ class AgentsController < ApplicationController
90 98
   end
91 99
 
92 100
   def diagram
93
-    @agents = current_user.agents.includes(:receivers)
101
+    @agents = if params[:scenario_id].present?
102
+                current_user.scenarios.find(params[:scenario_id]).agents.includes(:receivers)
103
+              else
104
+                current_user.agents.includes(:receivers)
105
+              end
94 106
   end
95 107
 
96 108
   def create
@@ -99,8 +111,8 @@ class AgentsController < ApplicationController
99 111
                                   params[:agent])
100 112
     respond_to do |format|
101 113
       if @agent.save
102
-        format.html { redirect_to agents_path, notice: 'Your Agent was successfully created.' }
103
-        format.json { render json: @agent, status: :created, location: @agent }
114
+        format.html { redirect_back "'#{@agent.name}' was successfully created." }
115
+        format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
104 116
       else
105 117
         format.html { render action: "new" }
106 118
         format.json { render json: @agent.errors, status: :unprocessable_entity }
@@ -113,14 +125,8 @@ class AgentsController < ApplicationController
113 125
 
114 126
     respond_to do |format|
115 127
       if @agent.update_attributes(params[:agent])
116
-        format.html {
117
-          if params[:return] == "show"
118
-            redirect_to agent_path(@agent), notice: 'Your Agent was successfully updated.'
119
-          else
120
-            redirect_to agents_path, notice: 'Your Agent was successfully updated.'
121
-          end
122
-        }
123
-        format.json { head :no_content }
128
+        format.html { redirect_back "'#{@agent.name}' was successfully updated." }
129
+        format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
124 130
       else
125 131
         format.html { render action: "edit" }
126 132
         format.json { render json: @agent.errors, status: :unprocessable_entity }
@@ -128,13 +134,39 @@ class AgentsController < ApplicationController
128 134
     end
129 135
   end
130 136
 
137
+  def leave_scenario
138
+    @agent = current_user.agents.find(params[:id])
139
+    @scenario = current_user.scenarios.find(params[:scenario_id])
140
+    @agent.scenarios.destroy(@scenario)
141
+
142
+    respond_to do |format|
143
+      format.html { redirect_back "'#{@agent.name}' removed from '#{@scenario.name}'" }
144
+      format.json { head :no_content }
145
+    end
146
+  end
147
+
131 148
   def destroy
132 149
     @agent = current_user.agents.find(params[:id])
133 150
     @agent.destroy
134 151
 
135 152
     respond_to do |format|
136
-      format.html { redirect_to agents_path }
153
+      format.html { redirect_back "'#{@agent.name}' deleted" }
137 154
       format.json { head :no_content }
138 155
     end
139 156
   end
157
+
158
+  protected
159
+
160
+  # Sanitize params[:return] to prevent open redirect attacks, a common security issue.
161
+  def redirect_back(message)
162
+    if params[:return] == "show" && @agent
163
+      path = agent_path(@agent)
164
+    elsif params[:return] =~ /\A#{Regexp::escape scenarios_path}\/\d+\Z/
165
+      path = params[:return]
166
+    else
167
+      path = agents_path
168
+    end
169
+
170
+    redirect_to path, notice: message
171
+  end
140 172
 end

+ 20 - 0
app/controllers/scenario_imports_controller.rb

@@ -0,0 +1,20 @@
1
+class ScenarioImportsController < ApplicationController
2
+  def new
3
+    @scenario_import = ScenarioImport.new(:url => params[:url])
4
+  end
5
+
6
+  def create
7
+    @scenario_import = ScenarioImport.new(params[:scenario_import])
8
+    @scenario_import.set_user(current_user)
9
+
10
+    if @scenario_import.will_request_local?(scenarios_url)
11
+      render :text => 'Sorry, you cannot import a Scenario by URL from your own Huginn server.' and return
12
+    end
13
+
14
+    if @scenario_import.valid? && @scenario_import.should_import? && @scenario_import.import
15
+      redirect_to @scenario_import.scenario, notice: "Import successful!"
16
+    else
17
+      render action: "new"
18
+    end
19
+  end
20
+end

+ 100 - 0
app/controllers/scenarios_controller.rb

@@ -0,0 +1,100 @@
1
+class ScenariosController < ApplicationController
2
+  skip_before_filter :authenticate_user!, :only => :export
3
+
4
+  def index
5
+    @scenarios = current_user.scenarios.page(params[:page])
6
+
7
+    respond_to do |format|
8
+      format.html
9
+      format.json { render json: @scenarios }
10
+    end
11
+  end
12
+
13
+  def new
14
+    @scenario = current_user.scenarios.build
15
+
16
+    respond_to do |format|
17
+      format.html
18
+      format.json { render json: @scenario }
19
+    end
20
+  end
21
+
22
+  def show
23
+    @scenario = current_user.scenarios.find(params[:id])
24
+    @agents = @scenario.agents.preload(:scenarios).page(params[:page])
25
+
26
+    respond_to do |format|
27
+      format.html
28
+      format.json { render json: @scenario }
29
+    end
30
+  end
31
+
32
+  def share
33
+    @scenario = current_user.scenarios.find(params[:id])
34
+
35
+    respond_to do |format|
36
+      format.html
37
+      format.json { render json: @scenario }
38
+    end
39
+  end
40
+
41
+  def export
42
+    @scenario = Scenario.find(params[:id])
43
+    raise ActiveRecord::RecordNotFound unless @scenario.public? || (current_user && current_user.id == @scenario.user_id)
44
+
45
+    @exporter = AgentsExporter.new(:name => @scenario.name,
46
+                                   :description => @scenario.description,
47
+                                   :guid => @scenario.guid,
48
+                                   :source_url => @scenario.public? && export_scenario_url(@scenario),
49
+                                   :agents => @scenario.agents)
50
+    response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"'
51
+    render :json => JSON.pretty_generate(@exporter.as_json)
52
+  end
53
+
54
+  def edit
55
+    @scenario = current_user.scenarios.find(params[:id])
56
+
57
+    respond_to do |format|
58
+      format.html
59
+      format.json { render json: @scenario }
60
+    end
61
+  end
62
+
63
+  def create
64
+    @scenario = current_user.scenarios.build(params[:scenario])
65
+
66
+    respond_to do |format|
67
+      if @scenario.save
68
+        format.html { redirect_to @scenario, notice: 'This Scenario was successfully created.' }
69
+        format.json { render json: @scenario, status: :created, location: @scenario }
70
+      else
71
+        format.html { render action: "new" }
72
+        format.json { render json: @scenario.errors, status: :unprocessable_entity }
73
+      end
74
+    end
75
+  end
76
+
77
+  def update
78
+    @scenario = current_user.scenarios.find(params[:id])
79
+
80
+    respond_to do |format|
81
+      if @scenario.update_attributes(params[:scenario])
82
+        format.html { redirect_to @scenario, notice: 'This Scenario was successfully updated.' }
83
+        format.json { head :no_content }
84
+      else
85
+        format.html { render action: "edit" }
86
+        format.json { render json: @scenario.errors, status: :unprocessable_entity }
87
+      end
88
+    end
89
+  end
90
+
91
+  def destroy
92
+    @scenario = current_user.scenarios.find(params[:id])
93
+    @scenario.destroy
94
+
95
+    respond_to do |format|
96
+      format.html { redirect_to scenarios_path }
97
+      format.json { head :no_content }
98
+    end
99
+  end
100
+end

+ 6 - 0
app/helpers/agent_helper.rb

@@ -6,6 +6,12 @@ module AgentHelper
6 6
     end
7 7
   end
8 8
 
9
+  def scenario_links(agent)
10
+    agent.scenarios.map { |scenario|
11
+      link_to(scenario.name, scenario, class: "label label-info")
12
+    }.join(" ").html_safe
13
+  end
14
+
9 15
   def agent_show_class(agent)
10 16
     agent.short_type.underscore.dasherize
11 17
   end

+ 1 - 0
app/helpers/dot_helper.rb

@@ -35,6 +35,7 @@ module DotHelper
35 35
           dot << '%s;' % disabled_label(agent)
36 36
         end
37 37
         agent.receivers.each do |receiver|
38
+          next unless agents.include?(receiver)
38 39
           dot << "%s->%s;" % [disabled_label(agent), disabled_label(receiver)]
39 40
         end
40 41
       end

+ 9 - 1
app/models/agent.rb

@@ -13,6 +13,7 @@ class Agent < ActiveRecord::Base
13 13
   include RDBMSFunctions
14 14
   include WorkingHelpers
15 15
   include LiquidInterpolatable
16
+  include HasGuid
16 17
 
17 18
   markdown_class_attributes :description, :event_description
18 19
 
@@ -23,13 +24,14 @@ class Agent < ActiveRecord::Base
23 24
 
24 25
   EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })]
25 26
 
26
-  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :keep_events_for, :propagate_immediately
27
+  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately
27 28
 
28 29
   json_serialize :options, :memory
29 30
 
30 31
   validates_presence_of :name, :user
31 32
   validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last)
32 33
   validate :sources_are_owned
34
+  validate :scenarios_are_owned
33 35
   validate :validate_schedule
34 36
   validate :validate_options
35 37
 
@@ -49,6 +51,8 @@ class Agent < ActiveRecord::Base
49 51
   has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver
50 52
   has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers
51 53
   has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources
54
+  has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent
55
+  has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents
52 56
 
53 57
   scope :of_type, lambda { |type|
54 58
     type = case type
@@ -207,6 +211,10 @@ class Agent < ActiveRecord::Base
207 211
     errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user }
208 212
   end
209 213
   
214
+  def scenarios_are_owned
215
+    errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user == user }
216
+  end
217
+
210 218
   def validate_schedule
211 219
     unless cannot_be_scheduled?
212 220
       errors.add(:schedule, "is not a valid schedule") unless SCHEDULES.include?(schedule.to_s)

+ 6 - 7
app/models/agents/pushover_agent.rb

@@ -19,13 +19,13 @@ module Agents
19 19
       Your event can provide any of the following optional parameters or you can provide defaults:
20 20
 
21 21
       * `device` - your user's device name to send the message directly to that device, rather than all of the user's devices
22
-      * `title` or `subject` - your notifications's title
22
+      * `title` or `subject` - your notification's title
23 23
       * `url` - a supplementary URL to show with your message - `512` Character Limit
24 24
       * `url_title` - a title for your supplementary URL, otherwise just the URL is shown - `100` Character Limit
25 25
       * `priority` - send as `-1` to always send as a quiet notification, `0` is default, `1` to display as high-priority and bypass the user's quiet hours, or `2` for emergency priority: [Please read Pushover Docs on Emergency Priority](https://pushover.net/api#priority)
26 26
       * `sound` - the name of one of the sounds supported by device clients to override the user's default sound choice. [See PushOver docs for sound options.](https://pushover.net/api#sounds)
27
-      * `retry` - Requred for emergency priority - Specifies how often (in seconds) the Pushover servers will send the same notification to the user. Minimum value: `30`
28
-      * `expire` - Requred for emergency priority - Specifies how many seconds your notification will continue to be retried for (every retry seconds). Maximum value: `86400`
27
+      * `retry` - Required for emergency priority - Specifies how often (in seconds) the Pushover servers will send the same notification to the user. Minimum value: `30`
28
+      * `expire` - Required for emergency priority - Specifies how many seconds your notification will continue to be retried for (every retry seconds). Maximum value: `86400`
29 29
 
30 30
       Your event can also pass along a timestamp parameter:
31 31
 
@@ -42,10 +42,10 @@ module Agents
42 42
         'title' => '',
43 43
         'url' => '',
44 44
         'url_title' => '',
45
-        'priority' => 0,
45
+        'priority' => '0',
46 46
         'sound' => 'pushover',
47
-        'retry' => 0,
48
-        'expire' => 0,
47
+        'retry' => '0',
48
+        'expire' => '0',
49 49
         'expected_receive_period_in_days' => '1'
50 50
       }
51 51
     end
@@ -103,6 +103,5 @@ module Agents
103 103
       response = HTTParty.post(API_URL, :query => post_params)
104 104
       puts response
105 105
     end
106
-
107 106
   end
108 107
 end

+ 19 - 0
app/models/scenario.rb

@@ -0,0 +1,19 @@
1
+class Scenario < ActiveRecord::Base
2
+  include HasGuid
3
+
4
+  attr_accessible :name, :agent_ids, :description, :public, :source_url
5
+
6
+  belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
7
+  has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
8
+  has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios
9
+
10
+  validates_presence_of :name, :user
11
+
12
+  validate :agents_are_owned
13
+
14
+  protected
15
+
16
+  def agents_are_owned
17
+    errors.add(:agents, "must be owned by you") unless agents.all? {|s| s.user == user }
18
+  end
19
+end

+ 256 - 0
app/models/scenario_import.rb

@@ -0,0 +1,256 @@
1
+require 'ostruct'
2
+
3
+# This is a helper class for managing Scenario imports, used by the ScenarioImportsController.  This class behaves much
4
+# like a normal ActiveRecord object, with validations and callbacks.  However, it is never persisted to the database.
5
+class ScenarioImport
6
+  include ActiveModel::Model
7
+  include ActiveModel::Callbacks
8
+  include ActiveModel::Validations::Callbacks
9
+
10
+  DANGEROUS_AGENT_TYPES = %w[Agents::ShellCommandAgent]
11
+  URL_REGEX = /\Ahttps?:\/\//i
12
+
13
+  attr_accessor :file, :url, :data, :do_import, :merges
14
+
15
+  attr_reader :user
16
+
17
+  before_validation :parse_file
18
+  before_validation :fetch_url
19
+
20
+  validate :validate_presence_of_file_url_or_data
21
+  validates_format_of :url, :with => URL_REGEX, :allow_nil => true, :allow_blank => true, :message => "appears to be invalid"
22
+  validate :validate_data
23
+  validate :generate_diff
24
+
25
+  def step_one?
26
+    data.blank?
27
+  end
28
+
29
+  def step_two?
30
+    data.present?
31
+  end
32
+
33
+  def set_user(user)
34
+    @user = user
35
+  end
36
+
37
+  def existing_scenario
38
+    @existing_scenario ||= user.scenarios.find_by(:guid => parsed_data["guid"])
39
+  end
40
+
41
+  def dangerous?
42
+    (parsed_data['agents'] || []).any? { |agent| DANGEROUS_AGENT_TYPES.include?(agent['type']) }
43
+  end
44
+
45
+  def parsed_data
46
+    @parsed_data ||= (data && JSON.parse(data) rescue {}) || {}
47
+  end
48
+
49
+  def agent_diffs
50
+    @agent_diffs || generate_diff
51
+  end
52
+
53
+  def should_import?
54
+    do_import == "1"
55
+  end
56
+
57
+  def import(options = {})
58
+    success = true
59
+    guid = parsed_data['guid']
60
+    description = parsed_data['description']
61
+    name = parsed_data['name']
62
+    links = parsed_data['links']
63
+    source_url = parsed_data['source_url'].presence || nil
64
+    @scenario = user.scenarios.where(:guid => guid).first_or_initialize
65
+    @scenario.update_attributes!(:name => name, :description => description,
66
+                                 :source_url => source_url, :public => false)
67
+
68
+    unless options[:skip_agents]
69
+      created_agents = agent_diffs.map do |agent_diff|
70
+        agent = agent_diff.agent || Agent.build_for_type("Agents::" + agent_diff.type.incoming, user)
71
+        agent.guid = agent_diff.guid.incoming
72
+        agent.attributes = { :name => agent_diff.name.updated,
73
+                             :disabled => agent_diff.disabled.updated, # == "true"
74
+                             :options => agent_diff.options.updated,
75
+                             :scenario_ids => [@scenario.id] }
76
+        agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present?
77
+        agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present?
78
+        agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true"
79
+        unless agent.save
80
+          success = false
81
+          errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}")
82
+        end
83
+        agent
84
+      end
85
+
86
+      links.each do |link|
87
+        receiver = created_agents[link['receiver']]
88
+        source = created_agents[link['source']]
89
+        receiver.sources << source unless receiver.sources.include?(source)
90
+      end
91
+    end
92
+
93
+    success
94
+  end
95
+
96
+  def scenario
97
+    @scenario || @existing_scenario
98
+  end
99
+
100
+  def will_request_local?(url_root)
101
+    data.blank? && file.blank? && url.present? && url.starts_with?(url_root)
102
+  end
103
+
104
+  protected
105
+
106
+  def parse_file
107
+    if data.blank? && file.present?
108
+      self.data = file.read
109
+    end
110
+  end
111
+
112
+  def fetch_url
113
+    if data.blank? && url.present? && url =~ URL_REGEX
114
+      self.data = Faraday.get(url).body
115
+    end
116
+  end
117
+
118
+  def validate_data
119
+    if data.present?
120
+      @parsed_data = JSON.parse(data) rescue {}
121
+      if (%w[name guid agents] - @parsed_data.keys).length > 0
122
+        errors.add(:base, "The provided data does not appear to be a valid Scenario.")
123
+        self.data = nil
124
+      end
125
+    else
126
+      @parsed_data = nil
127
+    end
128
+  end
129
+
130
+  def validate_presence_of_file_url_or_data
131
+    unless file.present? || url.present? || data.present?
132
+      errors.add(:base, "Please provide either a Scenario JSON File or a Public Scenario URL.")
133
+    end
134
+  end
135
+
136
+  def generate_diff
137
+    @agent_diffs = (parsed_data['agents'] || []).map.with_index do |agent_data, index|
138
+      # AgentDiff is defined at the end of this file.
139
+      agent_diff = AgentDiff.new(agent_data)
140
+      if existing_scenario
141
+        # If this Agent exists already, update the AgentDiff with the local version's information.
142
+        agent_diff.diff_with! existing_scenario.agents.find_by(:guid => agent_data['guid'])
143
+
144
+        begin
145
+          # Update the AgentDiff with any hand-merged changes coming from the UI.  This only happens when this
146
+          # Agent already exists locally and has conflicting changes.
147
+          agent_diff.update_from! merges[index.to_s] if merges
148
+        rescue JSON::ParserError
149
+          errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.")
150
+        end
151
+      end
152
+      agent_diff
153
+    end
154
+  end
155
+
156
+  # AgentDiff is a helper object that encapsulates an incoming Agent.  All fields will be returned as an array
157
+  # of either one or two values.  The first value is the incoming value, the second is the existing value, if
158
+  # it differs from the incoming value.
159
+  class AgentDiff < OpenStruct
160
+    class FieldDiff
161
+      attr_accessor :incoming, :current, :updated
162
+
163
+      def initialize(incoming)
164
+        @incoming = incoming
165
+        @updated = incoming
166
+      end
167
+
168
+      def set_current(current)
169
+        @current = current
170
+        @requires_merge = (incoming != current)
171
+      end
172
+
173
+      def requires_merge?
174
+        @requires_merge
175
+      end
176
+    end
177
+
178
+    def initialize(agent_data)
179
+      super()
180
+      @requires_merge = false
181
+      self.agent = nil
182
+      store! agent_data
183
+    end
184
+
185
+    BASE_FIELDS = %w[name schedule keep_events_for propagate_immediately disabled guid]
186
+
187
+    def agent_exists?
188
+      !!agent
189
+    end
190
+
191
+    def requires_merge?
192
+      @requires_merge
193
+    end
194
+
195
+    def store!(agent_data)
196
+      self.type = FieldDiff.new(agent_data["type"].split("::").pop)
197
+      self.options = FieldDiff.new(agent_data['options'] || {})
198
+      BASE_FIELDS.each do |option|
199
+        self[option] = FieldDiff.new(agent_data[option]) if agent_data.has_key?(option)
200
+      end
201
+    end
202
+
203
+    def diff_with!(agent)
204
+      return unless agent.present?
205
+
206
+      self.agent = agent
207
+
208
+      type.set_current(agent.short_type)
209
+      options.set_current(agent.options || {})
210
+
211
+      @requires_merge ||= type.requires_merge?
212
+      @requires_merge ||= options.requires_merge?
213
+
214
+      BASE_FIELDS.each do |field|
215
+        next unless self[field].present?
216
+        self[field].set_current(agent.send(field))
217
+
218
+        @requires_merge ||= self[field].requires_merge?
219
+      end
220
+    end
221
+
222
+    def update_from!(merges)
223
+      each_field do |field, value, selection_options|
224
+        value.updated = merges[field]
225
+      end
226
+
227
+      if options.requires_merge?
228
+        options.updated = JSON.parse(merges['options'])
229
+      end
230
+    end
231
+
232
+    def each_field
233
+      boolean = [["True", "true"], ["False", "false"]]
234
+      yield 'name', name if name.requires_merge?
235
+      yield 'schedule', schedule, Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] } if self['schedule'].present? && schedule.requires_merge?
236
+      yield 'keep_events_for', keep_events_for, Agent::EVENT_RETENTION_SCHEDULES if self['keep_events_for'].present? && keep_events_for.requires_merge?
237
+      yield 'propagate_immediately', propagate_immediately, boolean if self['propagate_immediately'].present? && propagate_immediately.requires_merge?
238
+      yield 'disabled', disabled, boolean if disabled.requires_merge?
239
+    end
240
+
241
+    # Unfortunately Ruby 1.9's OpenStruct doesn't expose [] and []=.
242
+    unless instance_methods.include?(:[]=)
243
+      def [](key)
244
+        self.send(sanitize key)
245
+      end
246
+
247
+      def []=(key, val)
248
+        self.send("#{sanitize key}=", val)
249
+      end
250
+
251
+      def sanitize(key)
252
+        key.gsub(/[^a-zA-Z0-9_-]/, '')
253
+      end
254
+    end
255
+  end
256
+end

+ 4 - 0
app/models/scenario_membership.rb

@@ -0,0 +1,4 @@
1
+class ScenarioMembership < ActiveRecord::Base
2
+  belongs_to :agent, :inverse_of => :scenario_memberships
3
+  belongs_to :scenario, :inverse_of => :scenario_memberships
4
+end

+ 1 - 0
app/models/user.rb

@@ -26,6 +26,7 @@ class User < ActiveRecord::Base
26 26
   has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
27 27
   has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
28 28
   has_many :logs, :through => :agents, :class_name => "AgentLog"
29
+  has_many :scenarios, :inverse_of => :user, :dependent => :destroy
29 30
 
30 31
   # Allow users to login via either email or username.
31 32
   def self.find_first_by_auth_conditions(warden_conditions)

+ 12 - 2
app/views/agents/_action_menu.html.erb

@@ -27,15 +27,25 @@
27 27
     <% end %>
28 28
   </li>
29 29
 
30
+  <% if agent.scenarios.length > 0 %>
31
+    <li class="divider"></li>
32
+
33
+    <% agent.scenarios.each do |scenario| %>
34
+      <li>
35
+        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from <span class='scenario label label-info'>#{h scenario.name}</span>".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
36
+      </li>
37
+    <% end %>
38
+  <% end %>
39
+
30 40
   <li class="divider"></li>
31 41
 
32 42
   <% if agent.can_create_events? && agent.events.count > 0 %>
33 43
     <li>
34
-      <%= link_to '<span class="color-danger glyphicon glyphicon-trash"></span> Delete all events'.html_safe, remove_events_agent_path(agent), method: :delete, data: {confirm: 'Are you sure you want to delete ALL events for this Agent?'}, :tabindex => "-1" %>
44
+      <%= link_to '<span class="color-danger glyphicon glyphicon-trash"></span> Delete all events'.html_safe, remove_events_agent_path(agent, :return => returnTo), method: :delete, data: {confirm: 'Are you sure you want to delete ALL emitted events for this Agent?'}, :tabindex => "-1" %>
35 45
     </li>
36 46
   <% end %>
37 47
 
38 48
   <li>
39
-    <%= link_to '<span class="color-danger glyphicon glyphicon-remove"></span> Delete agent'.html_safe, agent_path(agent), method: :delete, data: { confirm: 'Are you sure?' }, :tabindex => "-1" %>
49
+    <%= link_to '<span class="color-danger glyphicon glyphicon-remove"></span> Delete agent'.html_safe, agent_path(agent, :return => returnTo), method: :delete, data: { confirm: 'Are you sure that you want to permanently delete this Agent?' }, :tabindex => "-1" %>
40 50
   </li>
41 51
 </ul>

+ 14 - 2
app/views/agents/_form.html.erb

@@ -41,6 +41,7 @@
41 41
           <div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>">
42 42
             <div class="form-group">
43 43
               <%= f.label :keep_events_for, "Keep events" %>
44
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="In order to conserve disk space, you can choose to have events created by this Agent expire after a certain period of time.  Make sure you keep them long enough to allow any subsequent Agents to make use of them."></span>
44 45
               <%= f.select :keep_events_for, options_for_select(Agent::EVENT_RETENTION_SCHEDULES, @agent.keep_events_for), {}, :class => 'form-control' %>
45 46
             </div>
46 47
           </div>
@@ -59,13 +60,24 @@
59 60
               <% end %>
60 61
             </div>
61 62
           </div>
63
+
64
+          <% if current_user.scenario_count > 0 %>
65
+            <div class="form-group">
66
+              <%= f.label :scenarios %>
67
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="Use Scenarios to group sets of Agents, both for organization, and to make them easy to export and share."></span>
68
+              <%= f.select(:scenario_ids,
69
+                           options_for_select(current_user.scenarios.pluck(:name, :id), @agent.scenario_ids),
70
+                           {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
71
+            </div>
72
+          <% end %>
73
+
62 74
         </div>
63 75
 
64 76
         <!-- Form controls full width -->
65 77
         <div class="col-md-12">
66 78
           <div class="form-group">
67 79
             <%= f.label :options %>
68
-            <textarea rows="10" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
80
+            <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
69 81
               <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
70 82
             </textarea>
71 83
           </div>
@@ -92,7 +104,7 @@
92 104
 
93 105
   <div class='row'>
94 106
     <div class="col-md-12">
95
-      <%= f.submit :class => "btn btn-primary" %>
107
+      <%= f.submit "Save", :class => "btn btn-primary" %>
96 108
     </div>
97 109
   </div>
98 110
       

+ 75 - 0
app/views/agents/_table.html.erb

@@ -0,0 +1,75 @@
1
+<div class='table-responsive'>
2
+  <table class='table table-striped'>
3
+    <tr>
4
+      <th>Name</th>
5
+      <th>Schedule</th>
6
+      <th>Last Check</th>
7
+      <th>Last Event Out</th>
8
+      <th>Last Event In</th>
9
+      <th>Events Created</th>
10
+      <th>Working?</th>
11
+      <th></th>
12
+    </tr>
13
+
14
+    <% @agents.each do |agent| %>
15
+      <tr>
16
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
17
+          <%= link_to agent.name, agent_path(agent) %>
18
+          <br/>
19
+          <span class='text-muted'><%= agent.short_type.titleize %></span>
20
+          <% if agent.scenarios.present? %>
21
+            <span>
22
+              <%= scenario_links(agent) %>
23
+            </span>
24
+          <% end %>
25
+        </td>
26
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
27
+          <% if agent.can_be_scheduled? %>
28
+            <%= agent.schedule.to_s.humanize.titleize %>
29
+          <% else %>
30
+            <span class='not-applicable'></span>
31
+          <% end %>
32
+        </td>
33
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
34
+          <% if agent.can_be_scheduled? %>
35
+            <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
36
+          <% else %>
37
+            <span class='not-applicable'></span>
38
+          <% end %>
39
+        </td>
40
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
41
+          <% if agent.can_create_events? %>
42
+            <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
43
+          <% else %>
44
+            <span class='not-applicable'></span>
45
+          <% end %>
46
+        </td>
47
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
48
+          <% if agent.can_receive_events? %>
49
+            <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
50
+          <% else %>
51
+            <span class='not-applicable'></span>
52
+          <% end %>
53
+        </td>
54
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
55
+          <% if agent.can_create_events? %>
56
+            <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
57
+          <% else %>
58
+            <span class='not-applicable'></span>
59
+          <% end %>
60
+        </td>
61
+        <td><%= working(agent) %></td>
62
+        <td>
63
+          <div class="btn-group">
64
+            <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
65
+              <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
66
+            </button>
67
+            <%= render 'agents/action_menu', :agent => agent, :returnTo => (defined?(returnTo) && returnTo) || "index" %>
68
+          </div>
69
+        </td>
70
+      </tr>
71
+    <% end %>
72
+  </table>
73
+</div>
74
+
75
+<%= paginate @agents, :theme => 'twitter-bootstrap-3' %>

+ 1 - 1
app/views/agents/agent_views/manual_event_agent/_show.html.erb

@@ -14,7 +14,7 @@
14 14
 
15 15
 <script>
16 16
   $(function () {
17
-    var payloadJsonEditor = window.setupJsonEditor($(".payload-editor"));
17
+    var payloadJsonEditor = window.setupJsonEditor($(".payload-editor"))[0];
18 18
     $("#create-event-form").submit(function (e) {
19 19
       e.preventDefault();
20 20
       var $form = $("#create-event-form");

+ 1 - 1
app/views/agents/diagram.html.erb

@@ -5,7 +5,7 @@
5 5
         <h2>Agent Event Flow</h2>
6 6
       </div>
7 7
       <div class="btn-group">
8
-        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, agents_path, class: "btn btn-default" %>
8
+        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, (params[:scenario_id] ? scenario_path(params[:scenario_id]) : agents_path), class: "btn btn-default" %>
9 9
       </div>
10 10
 
11 11
       <div class='digraph'>

+ 1 - 70
app/views/agents/index.html.erb

@@ -5,76 +5,7 @@
5 5
         <h2>Your Agents</h2>
6 6
       </div>
7 7
 
8
-      <div class='table-responsive'>
9
-        <table class='table table-striped'>
10
-          <tr>
11
-            <th>Name</th>
12
-            <th>Schedule</th>
13
-            <th>Last Check</th>
14
-            <th>Last Event Out</th>
15
-            <th>Last Event In</th>
16
-            <th>Events Created</th>
17
-            <th>Working?</th>
18
-            <th></th>
19
-          </tr>
20
-
21
-          <% @agents.each do |agent| %>
22
-            <tr>
23
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
24
-                <%= link_to agent.name, agent_path(agent) %>
25
-                <br/>
26
-                <span class='text-muted'><%= agent.short_type.titleize %></span>
27
-              </td>
28
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
29
-                <% if agent.can_be_scheduled? %>
30
-                  <%= agent.schedule.to_s.humanize.titleize %>
31
-                <% else %>
32
-                  <span class='not-applicable'></span>
33
-                <% end %>
34
-              </td>
35
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
36
-                <% if agent.can_be_scheduled? %>
37
-                  <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
38
-                <% else %>
39
-                  <span class='not-applicable'></span>
40
-                <% end %>
41
-              </td>
42
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
43
-                <% if agent.can_create_events? %>
44
-                  <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
45
-                <% else %>
46
-                  <span class='not-applicable'></span>
47
-                <% end %>
48
-              </td>
49
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
50
-                <% if agent.can_receive_events? %>
51
-                  <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
52
-                <% else %>
53
-                  <span class='not-applicable'></span>
54
-                <% end %>
55
-              </td>
56
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
57
-                <% if agent.can_create_events? %>
58
-                  <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
59
-                <% else %>
60
-                  <span class='not-applicable'></span>
61
-                <% end %>
62
-              </td>
63
-              <td><%= working(agent) %></td>
64
-              <td>
65
-                <div class="btn-group">
66
-                  <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
67
-                    <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
68
-                  </button>
69
-                  <%= render 'action_menu', :agent => agent, :returnTo => "index" %>
70
-                </div>
71
-              </td>
72
-            </tr>
73
-          <% end %>
74
-        </table>
75
-      </div>
76
-
77
-      <%= paginate @agents, :theme => 'twitter-bootstrap-3' %>
8
+      <%= render 'agents/table' %>
78 9
 
79 10
       <br/>
80 11
 

+ 1 - 1
app/views/agents/show.html.erb

@@ -22,7 +22,7 @@
22 22
 
23 23
           <li class="dropdown">
24 24
             <a class="dropdown-toggle" data-toggle="dropdown" href="#"><span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span></a>
25
-            <%= render 'action_menu', :agent => @agent, :returnTo => "show" %>
25
+            <%= render 'agents/action_menu', :agent => @agent, :returnTo => "show" %>
26 26
           </li>
27 27
         </ul>
28 28
       </div>

+ 2 - 2
app/views/events/index.html.erb

@@ -20,13 +20,13 @@
20 20
           <% next unless event.agent %>
21 21
           <tr>
22 22
             <td><%= link_to event.agent.name, agent_path(event.agent) %></td>
23
-            <td><%= time_ago_in_words event.created_at %> ago</td>
23
+            <td title='<%= event.created_at %>'><%= time_ago_in_words event.created_at %> ago</td>
24 24
             <td class='payload'><%= truncate event.payload.to_json, :length => 90, :omission => "" %></td>
25 25
             <td>
26 26
               <div class="btn-group btn-group-xs">
27 27
                 <%= link_to 'Show', event_path(event), class: "btn btn-default" %>
28 28
                 <%= link_to 'Re-emit', reemit_event_path(event), method: :post, data: { confirm: 'Are you sure you want to duplicate this event and emit the new one now?' }, class: "btn btn-default" %>
29
-                <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
29
+                <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
30 30
               </div>
31 31
             </td>
32 32
           </tr>

+ 1 - 0
app/views/layouts/_navigation.html.erb

@@ -13,6 +13,7 @@
13 13
   <% if user_signed_in? %>
14 14
     <ul class='nav navbar-nav'>
15 15
       <%= nav_link "Agents", agents_path %>
16
+      <%= nav_link "Scenarios", scenarios_path %>
16 17
       <%= nav_link "Events", events_path %>
17 18
       <%= nav_link "Credentials", user_credentials_path %>
18 19
     </ul>

+ 14 - 10
app/views/layouts/application.html.erb

@@ -31,18 +31,22 @@
31 31
 
32 32
     <script>
33 33
       var agentPaths = {};
34
-      <% if current_user -%>
35
-        var myAgents = <%= Utils.jsonify(current_user.agents.select([:name, :id, :schedule]).inject({}) {|m, a| m[a.name] = agent_path(a); m }) %>;
34
+      var agentNames = [];
35
+      <% if current_user.present? -%>
36
+        var myAgents = <%= Utils.jsonify(current_user.agents.pluck(:name, :id).inject({}) {|m, a| m[a.first] = agent_path(a.last); m }) %>;
37
+        var myScenarios = <%= Utils.jsonify(current_user.scenarios.pluck(:name, :id).inject({}) {|m, s| m[s.first + " Scenario"] = scenario_path(s.last); m }) %>;
36 38
         $.extend(agentPaths, myAgents);
39
+        $.extend(agentPaths, myScenarios);
40
+        agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
41
+        agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
42
+        agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
43
+        agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
44
+        agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_agents_path %>;
45
+        agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
46
+
47
+
48
+        $.each(agentPaths, function(name, v) { agentNames.push(name); });
37 49
       <% end -%>
38
-      agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
39
-      agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
40
-      agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
41
-      agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
42
-      agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_agents_path %>;
43
-      agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
44
-      var agentNames = [];
45
-      $.each(agentPaths, function(name, v) { agentNames.push(name); });
46 50
     </script>
47 51
   </body>
48 52
 </html>

+ 31 - 0
app/views/scenario_imports/_step_one.html.erb

@@ -0,0 +1,31 @@
1
+<div class="row">
2
+  <div class="page-header">
3
+    <h2>
4
+      Import a Public Scenario
5
+    </h2>
6
+  </div>
7
+</div>
8
+
9
+<div class='row'>
10
+  <blockquote>You can import Scenarios, either from a <code>.json</code> file, or via a public
11
+    Scenario URL. When you import a Scenario, Huginn will keep track of where it came from and
12
+    later let you update it.</blockquote>
13
+</div>
14
+
15
+<div class='row'>
16
+  <div class="col-md-4">
17
+    <div class="form-group">
18
+      <%= f.label :url, 'Option 1: Provide a Public Scenario URL' %>
19
+      <%= f.text_field :url, :class => 'form-control', :placeholder => "Public Scenario URL" %>
20
+    </div>
21
+
22
+    <div class="form-group">
23
+      <%= f.label :file, 'Option 2: Upload a Scenario JSON File' %>
24
+      <%= f.file_field :file, :class => 'form-control' %>
25
+    </div>
26
+
27
+    <div class='form-actions'>
28
+      <%= f.submit "Start Import", :class => "btn btn-primary" %>
29
+    </div>
30
+  </div>
31
+</div>

+ 154 - 0
app/views/scenario_imports/_step_two.html.erb

@@ -0,0 +1,154 @@
1
+<div class="row">
2
+  <div class="col-md-12">
3
+    <% if @scenario_import.dangerous? %>
4
+      <div class="alert alert-danger">
5
+        <span class='glyphicon glyphicon-warning-sign'></span>
6
+        This Scenario contains one or more potentially dangerous Agents.
7
+        These may be able to run local commands or execute code.
8
+        Please be sure that you understand the Agent configurations before importing!
9
+      </div>
10
+    <% end %>
11
+
12
+    <% if @scenario_import.existing_scenario.present? %>
13
+      <div class="alert alert-warning">
14
+        <span class='glyphicon glyphicon-warning-sign'></span>
15
+        This Scenario already exists in your system. The import will update your existing
16
+        <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario's title
17
+        and
18
+        description. Below you can customize how the individual agents get updated.
19
+      </div>
20
+    <% end %>
21
+
22
+    <div class="page-header">
23
+      <h2>
24
+        <%= @scenario_import.parsed_data["name"] %>
25
+        <span class='text-muted'>
26
+          (<%= pluralize @scenario_import.parsed_data["agents"].length, "Agent" %>;
27
+          exported <%= time_ago_in_words Time.parse(@scenario_import.parsed_data["exported_at"]) %> ago)
28
+        </span>
29
+      </h2>
30
+    </div>
31
+
32
+    <% if @scenario_import.parsed_data["description"].present? %>
33
+      <blockquote><%= @scenario_import.parsed_data["description"] %></blockquote>
34
+    <% end %>
35
+
36
+  </div>
37
+</div>
38
+
39
+<div class='agent-import-list'>
40
+  <% @scenario_import.agent_diffs.each.with_index do |agent_diff, index| %>
41
+    <div class='agent-import' data-index='<%= index %>'>
42
+
43
+      <div class='row'>
44
+        <div class='col-md-12'>
45
+          <h3>
46
+            <a href='#' data-toggle="modal" data-target="#agent_options_<%= index %>"><%= agent_diff.name.incoming %></a>
47
+            <span class='text-muted'>
48
+              (<%= agent_diff.type.incoming %><% " -- WARNING: this Agent's type has been changed.  This import will likely fail!" if agent_diff.type.requires_merge? %>)
49
+            </span>
50
+          </h3>
51
+
52
+          <% if agent_diff.agent_exists? %>
53
+            <div class="instructions">
54
+              This Agent exists in your Huginn system.
55
+
56
+              <% if agent_diff.requires_merge? %>
57
+                Here are the differences between the incoming version and the one you have now. For each field, please
58
+                select which value you'd like to keep.
59
+              <% else %>
60
+                It's already up-to-date.
61
+              <% end %>
62
+            </div>
63
+          <% end %>
64
+        </div>
65
+      </div>
66
+
67
+      <div class="modal fade" id="agent_options_<%= index %>" tabindex="-1" role="dialog" aria-labelledby="modalLabel<%= index %>" aria-hidden="true">
68
+        <div class="modal-dialog modal-lg">
69
+          <div class="modal-content">
70
+            <div class="modal-header">
71
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
72
+              <h4 class="modal-title" id="modalLabel<%= index %>">Options for '<%= agent_diff.name.updated %>'</h4>
73
+            </div>
74
+            <div class="modal-body">
75
+              <pre class='options'><%= Utils.pretty_jsonify agent_diff.options.incoming %></pre>
76
+            </div>
77
+          </div>
78
+        </div>
79
+      </div>
80
+
81
+      <% agent_diff.each_field do |field, value, selection_options| %>
82
+        <div class='row'>
83
+          <div class='col-md-4'>
84
+            <div class="form-group">
85
+              <%= label_tag "scenario_import[merges][#{index}][#{field}]", field.titleize %>
86
+              <% if selection_options.present? %>
87
+                <div>
88
+                  Your current Agent's value is:
89
+                  <span class='current'><%= selection_options.find { |s| s.last.to_s == value.current.to_s }.first %></span>
90
+                </div>
91
+                <%= select_tag "scenario_import[merges][#{index}][#{field}]", options_for_select(selection_options, value.updated), :class => 'form-control' %>
92
+              <% else %>
93
+                <div>
94
+                  Your current Agent's value is: <span class='current'><%= value.current.to_s %></span>
95
+                </div>
96
+                <%= text_field_tag "scenario_import[merges][#{index}][#{field}]", value.updated, :class => 'form-control' %>
97
+              <% end %>
98
+            </div>
99
+          </div>
100
+        </div>
101
+      <% end %>
102
+
103
+      <div class='row'>
104
+        <% if agent_diff.options.requires_merge? %>
105
+          <div class='col-md-12'>
106
+            <label>Options</label>
107
+          </div>
108
+
109
+          <div class='col-md-6'>
110
+            <textarea name="scenario_import[merges][<%= index %>][options]" rows='15' class="form-control live-json-editor">
111
+              <%= Utils.pretty_jsonify(agent_diff.options.updated) %>
112
+            </textarea>
113
+          </div>
114
+
115
+          <div class='col-md-6'>
116
+            <div>
117
+              Your current options:
118
+            </div>
119
+            <pre class='options'><%= Utils.pretty_jsonify agent_diff.options.current %></pre>
120
+          </div>
121
+        <% end %>
122
+      </div>
123
+    </div>
124
+  <% end %>
125
+</div>
126
+
127
+<div class='row'>
128
+  <div class='col-md-12'>
129
+    <div class="checkbox">
130
+      <%= f.label :do_import do %>
131
+        <%= f.check_box :do_import %> I confirm that I want to import these Agents.
132
+      <% end %>
133
+    </div>
134
+
135
+    <div class='form-actions'>
136
+      <%= f.submit "Finish Import", :class => "btn btn-primary" %>
137
+    </div>
138
+  </div>
139
+</div>
140
+
141
+
142
+<script>
143
+//  $(function() {
144
+//    $('.agent-import-list .options-toggle').on('click', function (e) {
145
+//      e.preventDefault();
146
+//      $(this).siblings('.options').slideToggle()
147
+//      if ($(this).text() == "Show Options") {
148
+//        $(this).text("Hide Options");
149
+//      } else {
150
+//        $(this).text("Show Options");
151
+//      }
152
+//    });
153
+//  });
154
+</script>

+ 32 - 0
app/views/scenario_imports/new.html.erb

@@ -0,0 +1,32 @@
1
+<div class='container scenario-import'>
2
+  <div class="row">
3
+    <div class="col-md-12">
4
+      <% if @scenario_import.errors.any? %>
5
+        <div class="row well">
6
+          <h2><%= pluralize(@scenario_import.errors.count, "error") %> prohibited this Scenario from being imported:</h2>
7
+          <% @scenario_import.errors.full_messages.each do |msg| %>
8
+            <p class='text-warning'><%= msg %></p>
9
+          <% end %>
10
+        </div>
11
+      <% end %>
12
+    </div>
13
+  </div>
14
+
15
+  <%= form_for @scenario_import, :multipart => true do |f| %>
16
+    <%= f.hidden_field :data %>
17
+
18
+    <% if @scenario_import.step_one? %>
19
+      <%= render 'step_one', :f => f %>
20
+    <% elsif @scenario_import.step_two? %>
21
+      <%= render 'step_two', :f => f %>
22
+    <% end %>
23
+  <% end %>
24
+
25
+  <hr />
26
+
27
+  <div class="row">
28
+    <div class="col-md-12">
29
+      <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
30
+    </div>
31
+  </div>
32
+</div>

+ 57 - 0
app/views/scenarios/_form.html.erb

@@ -0,0 +1,57 @@
1
+<%= form_for(@scenario, :method => @scenario.new_record? ? "POST" : "PUT") do |f| %>
2
+  <% if @scenario.errors.any? %>
3
+    <div class="row well">
4
+      <h2><%= pluralize(@scenario.errors.count, "error") %> prohibited this Scenario from being saved:</h2>
5
+      <% @scenario.errors.full_messages.each do |msg| %>
6
+        <p class='text-warning'><%= msg %></p>
7
+      <% end %>
8
+    </div>
9
+  <% end %>
10
+
11
+  <div class="row">
12
+    <div class="col-md-4">
13
+      <div class="form-group">
14
+        <%= f.label :name %>
15
+        <%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %>
16
+      </div>
17
+    </div>
18
+  </div>
19
+
20
+  <div class="row">
21
+    <div class="col-md-8">
22
+      <div class="form-group">
23
+        <%= f.label :description, "Optional Description" %>
24
+        <%= f.text_area :description, :rows => 10, :class => 'form-control', :placeholder => "Optionally describe what this Scenario will do.  If this will be public, you should also include some contact information." %>
25
+      </div>
26
+
27
+      <div class="checkbox">
28
+        <%= f.label :public do %>
29
+          <%= f.check_box :public %> Share this Scenario publicly
30
+        <% end %>
31
+        <span class="glyphicon glyphicon-question-sign hover-help" data-content="When selected, this Scenario and all Agents in it will be made public.  An export URL will be available to share with other Huginn users.  Be very careful that you do not have secret credentials stored in these Agents' options.  Instead, use Credentials by reference."></span>
32
+      </div>
33
+
34
+    </div>
35
+  </div>
36
+
37
+  <div class="row">
38
+    <div class="col-md-4">
39
+      <div class="form-group">
40
+        <div>
41
+          <%= f.label :agents %>
42
+          <%= f.select(:agent_ids,
43
+                       options_for_select(current_user.agents.pluck(:name, :id), @scenario.agent_ids),
44
+                       {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
45
+        </div>
46
+      </div>
47
+    </div>
48
+  </div>
49
+
50
+  <div class="row">
51
+    <div class="col-md-12">
52
+      <div class='form-actions' style='clear: both'>
53
+        <%= f.submit "Save Scenario", :class => "btn btn-primary" %>
54
+      </div>
55
+    </div>
56
+  </div>
57
+<% end %>

+ 21 - 0
app/views/scenarios/edit.html.erb

@@ -0,0 +1,21 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Edit Scenario
7
+        </h2>
8
+      </div>
9
+
10
+      <%= render 'form' %>
11
+
12
+      <hr>
13
+
14
+      <div class="row">
15
+        <div class="col-md-12">
16
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
17
+        </div>
18
+      </div>
19
+    </div>
20
+  </div>
21
+</div>

+ 50 - 0
app/views/scenarios/index.html.erb

@@ -0,0 +1,50 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Your Scenarios
7
+        </h2>
8
+      </div>
9
+
10
+      <blockquote>Scenarios are named groups of Agents.  Scenarios allow you to organize your agents,
11
+        and to import and export sets of Agents to share.</blockquote>
12
+
13
+      <table class='table table-striped'>
14
+        <tr>
15
+          <th>Name</th>
16
+          <th>Agents</th>
17
+          <th>Public</th>
18
+          <th></th>
19
+        </tr>
20
+
21
+        <% @scenarios.each do |scenario| %>
22
+          <tr>
23
+            <td>
24
+              <%= link_to(scenario.name, scenario) %>
25
+            </td>
26
+            <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
27
+            <td><%= scenario.public? ? "yes" : "no" %></td>
28
+            <td>
29
+              <div class="btn-group btn-group-xs" style="float: right">
30
+                <%= link_to 'Show', scenario, class: "btn btn-default" %>
31
+                <%= link_to 'Edit', edit_scenario_path(scenario), class: "btn btn-default" %>
32
+                <%= link_to 'Share', share_scenario_path(scenario), class: "btn btn-default" %>
33
+                <%= link_to 'Delete', scenario_path(scenario), method: :delete, data: { confirm: "This will remove the '#{scenario.name}' Scenerio from all Agents and delete it.  Are you sure?" }, class: "btn btn-default" %>
34
+              </div>
35
+            </td>
36
+          </tr>
37
+        <% end %>
38
+      </table>
39
+
40
+      <%= paginate @scenarios, :theme => 'twitter-bootstrap' %>
41
+
42
+      <br/>
43
+
44
+      <div class="btn-group">
45
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> New Scenario'.html_safe, new_scenario_path, class: "btn btn-default" %>
46
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> Import Scenario'.html_safe, new_scenario_imports_path, class: "btn btn-default" %>
47
+      </div>
48
+    </div>
49
+  </div>
50
+</div>

+ 21 - 0
app/views/scenarios/new.html.erb

@@ -0,0 +1,21 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Create a new Scenario
7
+        </h2>
8
+      </div>
9
+
10
+      <%= render 'form' %>
11
+
12
+      <hr>
13
+
14
+      <div class="row">
15
+        <div class="col-md-12">
16
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
17
+        </div>
18
+      </div>
19
+    </div>
20
+  </div>
21
+</div>

+ 33 - 0
app/views/scenarios/share.html.erb

@@ -0,0 +1,33 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2>
6
+      </div>
7
+
8
+      <p>
9
+        <strong>Please be sure that none of the Agents in this Scenario have sensitive data in their settings before sharing!</strong>
10
+      </p>
11
+
12
+      <% if @scenario.public? %>
13
+        <p>
14
+          This Scenario is public.  You can <%= link_to "download and share your export file", export_scenario_path(@scenario, :format => :json) %>, or give out this URL:
15
+        </p>
16
+
17
+        <form onsubmit='return false;'>
18
+          <input type='text' class='form-control' value='<%= export_scenario_url(@scenario, :format => :json) %>' onclick="return this.select();"/>
19
+        </form>
20
+      <% else %>
21
+        This Scenario is not public.  You can share it by <%= link_to "downloading and sharing your export file", export_scenario_path(@scenario, :format => :json) %>.
22
+      <% end %>
23
+
24
+      <hr>
25
+
26
+      <div class="row">
27
+        <div class="col-md-12">
28
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenario_path(@scenario), class: "btn btn-default" %>
29
+        </div>
30
+      </div>
31
+    </div>
32
+  </div>
33
+</div>

+ 28 - 0
app/views/scenarios/show.html.erb

@@ -0,0 +1,28 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2><span class='label label-info scenario'><%= @scenario.name %></span> <%= "Public" if @scenario.public? %> Scenario</h2>
6
+      </div>
7
+
8
+      <% if @scenario.description.present? %>
9
+        <blockquote><%= @scenario.description %></blockquote>
10
+      <% end %>
11
+
12
+      <%= render 'agents/table', :returnTo => scenario_path(@scenario) %>
13
+
14
+      <br/>
15
+
16
+      <div class="btn-group">
17
+        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
18
+        <%= link_to '<span class="glyphicon glyphicon-random"></span> View Diagram'.html_safe, diagram_agents_path(:scenario_id => @scenario.to_param), class: "btn btn-default" %>
19
+        <%= link_to '<span class="glyphicon glyphicon-edit"></span> Edit'.html_safe, edit_scenario_path(@scenario), class: "btn btn-default" %>
20
+        <% if @scenario.source_url.present? %>
21
+          <%= link_to '<span class="glyphicon glyphicon-plus"></span> Update'.html_safe, new_scenario_imports_path(:url => @scenario.source_url), class: "btn btn-default" %>
22
+        <% end %>
23
+        <%= link_to '<span class="glyphicon glyphicon-share-alt"></span> Share'.html_safe, share_scenario_path(@scenario), class: "btn btn-default" %>
24
+        <%= link_to '<span class="glyphicon glyphicon-trash"></span> Delete'.html_safe, scenario_path(@scenario), method: :delete, data: { confirm: "This will remove the '#{@scenario.name}' Scenerio from all Agents and delete it.  Are you sure?" }, class: "btn btn-default" %>
25
+      </div>
26
+    </div>
27
+  </div>
28
+</div>

+ 12 - 0
config/routes.rb

@@ -3,6 +3,7 @@ Huginn::Application.routes.draw do
3 3
     member do
4 4
       post :run
5 5
       post :handle_details_post
6
+      put :leave_scenario
6 7
       delete :remove_events
7 8
     end
8 9
 
@@ -26,6 +27,17 @@ Huginn::Application.routes.draw do
26 27
     end
27 28
   end
28 29
 
30
+  resources :scenarios do
31
+    collection do
32
+      resource :scenario_imports, :only => [:new, :create]
33
+    end
34
+
35
+    member do
36
+      get :share
37
+      get :export
38
+    end
39
+  end
40
+
29 41
   resources :user_credentials, :except => :show
30 42
 
31 43
   get "/worker_status" => "worker_status#show"

+ 12 - 0
db/migrate/20140509170420_create_scenarios.rb

@@ -0,0 +1,12 @@
1
+class CreateScenarios < ActiveRecord::Migration
2
+  def change
3
+    create_table :scenarios do |t|
4
+      t.string :name, :null => false
5
+      t.integer :user_id, :null => false
6
+
7
+      t.timestamps
8
+    end
9
+
10
+    add_column :users, :scenario_count, :integer, :null => false, :default => 0
11
+  end
12
+end

+ 10 - 0
db/migrate/20140509170443_create_scenario_memberships.rb

@@ -0,0 +1,10 @@
1
+class CreateScenarioMemberships < ActiveRecord::Migration
2
+  def change
3
+    create_table :scenario_memberships do |t|
4
+      t.integer :agent_id, :null => false
5
+      t.integer :scenario_id, :null => false
6
+
7
+      t.timestamps
8
+    end
9
+  end
10
+end

+ 8 - 0
db/migrate/20140531232016_add_fields_to_scenarios.rb

@@ -0,0 +1,8 @@
1
+class AddFieldsToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_column :scenarios, :description, :text
4
+    add_column :scenarios, :public, :boolean, :default => false, :null => false
5
+    add_column :scenarios, :guid, :string, :null => false
6
+    add_column :scenarios, :source_url, :string
7
+  end
8
+end

+ 7 - 0
db/migrate/20140602014917_add_indices_to_scenarios.rb

@@ -0,0 +1,7 @@
1
+class AddIndicesToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_index :scenarios, [:user_id, :guid], :unique => true
4
+    add_index :scenario_memberships, :agent_id
5
+    add_index :scenario_memberships, :scenario_id
6
+  end
7
+end

+ 15 - 0
db/migrate/20140605032822_add_guid_to_agents.rb

@@ -0,0 +1,15 @@
1
+class AddGuidToAgents < ActiveRecord::Migration
2
+  class Agent < ActiveRecord::Base; end
3
+
4
+  def change
5
+    add_column :agents, :guid, :string
6
+
7
+    Agent.find_each do |agent|
8
+      agent.update_attribute :guid, SecureRandom.hex
9
+    end
10
+
11
+    change_column_null :agents, :guid, false
12
+
13
+    add_index :agents, :guid
14
+  end
15
+end

+ 90 - 64
db/schema.rb

@@ -9,23 +9,23 @@
9 9
 # from scratch. The latter is a flawed and unsustainable approach (the more migrations
10 10
 # you'll amass, the slower it'll run and the greater likelihood for issues).
11 11
 #
12
-# It's strongly recommended to check this file into your version control system.
12
+# It's strongly recommended that you check this file into your version control system.
13 13
 
14
-ActiveRecord::Schema.define(:version => 20140408150825) do
14
+ActiveRecord::Schema.define(version: 20140605032822) do
15 15
 
16
-  create_table "agent_logs", :force => true do |t|
17
-    t.integer  "agent_id",                         :null => false
18
-    t.text     "message",                          :null => false
19
-    t.integer  "level",             :default => 3, :null => false
16
+  create_table "agent_logs", force: true do |t|
17
+    t.integer  "agent_id",                                       null: false
18
+    t.text     "message",           limit: 16777215,             null: false
19
+    t.integer  "level",                              default: 3, null: false
20 20
     t.integer  "inbound_event_id"
21 21
     t.integer  "outbound_event_id"
22
-    t.datetime "created_at",                       :null => false
23
-    t.datetime "updated_at",                       :null => false
22
+    t.datetime "created_at",                                     null: false
23
+    t.datetime "updated_at",                                     null: false
24 24
   end
25 25
 
26
-  create_table "agents", :force => true do |t|
26
+  create_table "agents", force: true do |t|
27 27
     t.integer  "user_id"
28
-    t.text     "options"
28
+    t.text     "options",               limit: 16777215
29 29
     t.string   "type"
30 30
     t.string   "name"
31 31
     t.string   "schedule"
@@ -33,98 +33,124 @@ ActiveRecord::Schema.define(:version => 20140408150825) do
33 33
     t.datetime "last_check_at"
34 34
     t.datetime "last_receive_at"
35 35
     t.integer  "last_checked_event_id"
36
-    t.datetime "created_at",                                                     :null => false
37
-    t.datetime "updated_at",                                                     :null => false
38
-    t.text     "memory",                :limit => 2147483647
36
+    t.datetime "created_at",                                               null: false
37
+    t.datetime "updated_at",                                               null: false
38
+    t.text     "memory",                limit: 2147483647
39 39
     t.datetime "last_web_request_at"
40
-    t.integer  "keep_events_for",                             :default => 0,     :null => false
41 40
     t.datetime "last_event_at"
42 41
     t.datetime "last_error_log_at"
43
-    t.boolean  "propagate_immediately",                       :default => false, :null => false
44
-    t.boolean  "disabled",                                    :default => false, :null => false
42
+    t.integer  "keep_events_for",                          default: 0,     null: false
43
+    t.boolean  "propagate_immediately",                    default: false, null: false
44
+    t.boolean  "disabled",                                 default: false, null: false
45
+    t.string   "guid",                                                     null: false
45 46
   end
46 47
 
47
-  add_index "agents", ["schedule"], :name => "index_agents_on_schedule"
48
-  add_index "agents", ["type"], :name => "index_agents_on_type"
49
-  add_index "agents", ["user_id", "created_at"], :name => "index_agents_on_user_id_and_created_at"
48
+  add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
49
+  add_index "agents", ["schedule"], name: "index_agents_on_schedule", using: :btree
50
+  add_index "agents", ["type"], name: "index_agents_on_type", using: :btree
51
+  add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
50 52
 
51
-  create_table "delayed_jobs", :force => true do |t|
52
-    t.integer  "priority",                       :default => 0
53
-    t.integer  "attempts",                       :default => 0
54
-    t.text     "handler",    :limit => 16777215
55
-    t.text     "last_error"
53
+  create_table "delayed_jobs", force: true do |t|
54
+    t.integer  "priority",                    default: 0
55
+    t.integer  "attempts",                    default: 0
56
+    t.text     "handler",    limit: 16777215
57
+    t.text     "last_error", limit: 16777215
56 58
     t.datetime "run_at"
57 59
     t.datetime "locked_at"
58 60
     t.datetime "failed_at"
59 61
     t.string   "locked_by"
60 62
     t.string   "queue"
61
-    t.datetime "created_at",                                    :null => false
62
-    t.datetime "updated_at",                                    :null => false
63
+    t.datetime "created_at",                              null: false
64
+    t.datetime "updated_at",                              null: false
63 65
   end
64 66
 
65
-  add_index "delayed_jobs", ["priority", "run_at"], :name => "delayed_jobs_priority"
67
+  add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree
66 68
 
67
-  create_table "events", :force => true do |t|
69
+  create_table "events", force: true do |t|
68 70
     t.integer  "user_id"
69 71
     t.integer  "agent_id"
70
-    t.decimal  "lat",                            :precision => 15, :scale => 10
71
-    t.decimal  "lng",                            :precision => 15, :scale => 10
72
-    t.text     "payload",    :limit => 16777215
73
-    t.datetime "created_at",                                                     :null => false
74
-    t.datetime "updated_at",                                                     :null => false
72
+    t.decimal  "lat",                           precision: 15, scale: 10
73
+    t.decimal  "lng",                           precision: 15, scale: 10
74
+    t.text     "payload",    limit: 2147483647
75
+    t.datetime "created_at",                                              null: false
76
+    t.datetime "updated_at",                                              null: false
75 77
     t.datetime "expires_at"
76 78
   end
77 79
 
78
-  add_index "events", ["agent_id", "created_at"], :name => "index_events_on_agent_id_and_created_at"
79
-  add_index "events", ["expires_at"], :name => "index_events_on_expires_at"
80
-  add_index "events", ["user_id", "created_at"], :name => "index_events_on_user_id_and_created_at"
80
+  add_index "events", ["agent_id", "created_at"], name: "index_events_on_agent_id_and_created_at", using: :btree
81
+  add_index "events", ["expires_at"], name: "index_events_on_expires_at", using: :btree
82
+  add_index "events", ["user_id", "created_at"], name: "index_events_on_user_id_and_created_at", using: :btree
81 83
 
82
-  create_table "links", :force => true do |t|
84
+  create_table "links", force: true do |t|
83 85
     t.integer  "source_id"
84 86
     t.integer  "receiver_id"
85
-    t.datetime "created_at",                          :null => false
86
-    t.datetime "updated_at",                          :null => false
87
-    t.integer  "event_id_at_creation", :default => 0, :null => false
87
+    t.datetime "created_at",                       null: false
88
+    t.datetime "updated_at",                       null: false
89
+    t.integer  "event_id_at_creation", default: 0, null: false
88 90
   end
89 91
 
90
-  add_index "links", ["receiver_id", "source_id"], :name => "index_links_on_receiver_id_and_source_id"
91
-  add_index "links", ["source_id", "receiver_id"], :name => "index_links_on_source_id_and_receiver_id"
92
+  add_index "links", ["receiver_id", "source_id"], name: "index_links_on_receiver_id_and_source_id", using: :btree
93
+  add_index "links", ["source_id", "receiver_id"], name: "index_links_on_source_id_and_receiver_id", using: :btree
92 94
 
93
-  create_table "user_credentials", :force => true do |t|
94
-    t.integer  "user_id",                              :null => false
95
-    t.string   "credential_name",                      :null => false
96
-    t.text     "credential_value",                     :null => false
97
-    t.datetime "created_at",                           :null => false
98
-    t.datetime "updated_at",                           :null => false
99
-    t.string   "mode",             :default => "text", :null => false
95
+  create_table "scenario_memberships", force: true do |t|
96
+    t.integer  "agent_id",    null: false
97
+    t.integer  "scenario_id", null: false
98
+    t.datetime "created_at"
99
+    t.datetime "updated_at"
100 100
   end
101 101
 
102
-  add_index "user_credentials", ["user_id", "credential_name"], :name => "index_user_credentials_on_user_id_and_credential_name", :unique => true
102
+  add_index "scenario_memberships", ["agent_id"], name: "index_scenario_memberships_on_agent_id", using: :btree
103
+  add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree
103 104
 
104
-  create_table "users", :force => true do |t|
105
-    t.string   "email",                  :default => "",    :null => false
106
-    t.string   "encrypted_password",     :default => "",    :null => false
105
+  create_table "scenarios", force: true do |t|
106
+    t.string   "name",                        null: false
107
+    t.integer  "user_id",                     null: false
108
+    t.datetime "created_at"
109
+    t.datetime "updated_at"
110
+    t.text     "description"
111
+    t.boolean  "public",      default: false, null: false
112
+    t.string   "guid",                        null: false
113
+    t.string   "source_url"
114
+  end
115
+
116
+  add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
117
+
118
+  create_table "user_credentials", force: true do |t|
119
+    t.integer  "user_id",                           null: false
120
+    t.string   "credential_name",                   null: false
121
+    t.text     "credential_value",                  null: false
122
+    t.datetime "created_at",                        null: false
123
+    t.datetime "updated_at",                        null: false
124
+    t.string   "mode",             default: "text", null: false
125
+  end
126
+
127
+  add_index "user_credentials", ["user_id", "credential_name"], name: "index_user_credentials_on_user_id_and_credential_name", unique: true, using: :btree
128
+
129
+  create_table "users", force: true do |t|
130
+    t.string   "email",                  default: "",    null: false
131
+    t.string   "encrypted_password",     default: "",    null: false
107 132
     t.string   "reset_password_token"
108 133
     t.datetime "reset_password_sent_at"
109 134
     t.datetime "remember_created_at"
110
-    t.integer  "sign_in_count",          :default => 0
135
+    t.integer  "sign_in_count",          default: 0
111 136
     t.datetime "current_sign_in_at"
112 137
     t.datetime "last_sign_in_at"
113 138
     t.string   "current_sign_in_ip"
114 139
     t.string   "last_sign_in_ip"
115
-    t.datetime "created_at",                                :null => false
116
-    t.datetime "updated_at",                                :null => false
117
-    t.boolean  "admin",                  :default => false, :null => false
118
-    t.integer  "failed_attempts",        :default => 0
140
+    t.datetime "created_at",                             null: false
141
+    t.datetime "updated_at",                             null: false
142
+    t.boolean  "admin",                  default: false, null: false
143
+    t.integer  "failed_attempts",        default: 0
119 144
     t.string   "unlock_token"
120 145
     t.datetime "locked_at"
121
-    t.string   "username",                                  :null => false
122
-    t.string   "invitation_code",                           :null => false
146
+    t.string   "username",                               null: false
147
+    t.string   "invitation_code",                        null: false
148
+    t.integer  "scenario_count",         default: 0,     null: false
123 149
   end
124 150
 
125
-  add_index "users", ["email"], :name => "index_users_on_email", :unique => true
126
-  add_index "users", ["reset_password_token"], :name => "index_users_on_reset_password_token", :unique => true
127
-  add_index "users", ["unlock_token"], :name => "index_users_on_unlock_token", :unique => true
128
-  add_index "users", ["username"], :name => "index_users_on_username", :unique => true
151
+  add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree
152
+  add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree
153
+  add_index "users", ["unlock_token"], name: "index_users_on_unlock_token", unique: true, using: :btree
154
+  add_index "users", ["username"], name: "index_users_on_username", unique: true, using: :btree
129 155
 
130 156
 end

+ 54 - 0
lib/agents_exporter.rb

@@ -0,0 +1,54 @@
1
+class AgentsExporter
2
+  attr_accessor :options
3
+
4
+  def initialize(options)
5
+    self.options = options
6
+  end
7
+
8
+  # Filename should have no commas or special characters to support Content-Disposition on older browsers.
9
+  def filename
10
+    ((options[:name] || '').downcase.gsub(/[^a-z0-9_-]/, '-').gsub(/-+/, '-').gsub(/^-|-$/, '').presence || 'exported-agents') + ".json"
11
+  end
12
+
13
+  def as_json(opts = {})
14
+    {
15
+      :name => options[:name].presence || 'No name provided',
16
+      :description => options[:description].presence || 'No description provided',
17
+      :source_url => options[:source_url],
18
+      :guid => options[:guid],
19
+      :exported_at => Time.now.utc.iso8601,
20
+      :agents => agents.map { |agent| agent_as_json(agent) },
21
+      :links => links
22
+    }
23
+  end
24
+
25
+  def agents
26
+    options[:agents].to_a
27
+  end
28
+
29
+  def links
30
+    agent_ids = agents.map(&:id)
31
+
32
+    contained_links = agents.map.with_index do |agent, index|
33
+      agent.links_as_source.where(:receiver_id => agent_ids).map do |link|
34
+        { :source => index, :receiver => agent_ids.index(link.receiver_id) }
35
+      end
36
+    end
37
+
38
+    contained_links.flatten.compact
39
+  end
40
+
41
+  def agent_as_json(agent)
42
+    {
43
+      :type => agent.type,
44
+      :name => agent.name,
45
+      :disabled => agent.disabled,
46
+      :guid => agent.guid,
47
+      :options => agent.options
48
+    }.tap do |options|
49
+      options[:schedule] = agent.schedule if agent.can_be_scheduled?
50
+      options[:keep_events_for] = agent.keep_events_for if agent.can_create_events?
51
+      options[:propagate_immediately] = agent.propagate_immediately if agent.can_receive_events?
52
+    end
53
+  end
54
+end

spec/lib/inheritance_tracking_spec.rb → spec/concerns/inheritance_tracking_spec.rb


+ 103 - 0
spec/controllers/agents_controller_spec.rb

@@ -34,6 +34,47 @@ describe AgentsController do
34 34
     end
35 35
   end
36 36
 
37
+  describe "POST run" do
38
+    it "triggers Agent.async_check with the Agent's ID" do
39
+      sign_in users(:bob)
40
+      mock(Agent).async_check(agents(:bob_manual_event_agent).id)
41
+      post :run, :id => agents(:bob_manual_event_agent).to_param
42
+    end
43
+
44
+    it "can only be accessed by the Agent's owner" do
45
+      sign_in users(:jane)
46
+      lambda {
47
+        post :run, :id => agents(:bob_manual_event_agent).to_param
48
+      }.should raise_error(ActiveRecord::RecordNotFound)
49
+    end
50
+  end
51
+
52
+  describe "POST remove_events" do
53
+    it "deletes all events created by the given Agent" do
54
+      sign_in users(:bob)
55
+      agent_event = events(:bob_website_agent_event).id
56
+      other_event = events(:jane_website_agent_event).id
57
+      post :remove_events, :id => agents(:bob_website_agent).to_param
58
+      Event.where(:id => agent_event).count.should == 0
59
+      Event.where(:id => other_event).count.should == 1
60
+    end
61
+
62
+    it "can only be accessed by the Agent's owner" do
63
+      sign_in users(:jane)
64
+      lambda {
65
+        post :remove_events, :id => agents(:bob_website_agent).to_param
66
+      }.should raise_error(ActiveRecord::RecordNotFound)
67
+    end
68
+  end
69
+
70
+  describe "POST propagate" do
71
+    it "runs event propagation for all Agents" do
72
+      sign_in users(:bob)
73
+      mock.proxy(Agent).receive!
74
+      post :propagate
75
+    end
76
+  end
77
+
37 78
   describe "GET show" do
38 79
     it "only shows Agents for the current user" do
39 80
       sign_in users(:bob)
@@ -152,18 +193,80 @@ describe AgentsController do
152 193
       }.should raise_error(ActiveRecord::RecordNotFound)
153 194
     end
154 195
 
196
+    it "accepts JSON requests" do
197
+      sign_in users(:bob)
198
+      post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :format => :json
199
+      agents(:bob_website_agent).reload.name.should == "New name"
200
+      JSON.parse(response.body)['name'].should == "New name"
201
+      response.should be_success
202
+    end
203
+
155 204
     it "will not accept Agent sources owned by other users" do
156 205
       sign_in users(:bob)
157 206
       post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:source_ids => [agents(:jane_weather_agent).id])
158 207
       assigns(:agent).should have(1).errors_on(:sources)
159 208
     end
160 209
 
210
+    it "will not accept Scenarios owned by other users" do
211
+      sign_in users(:bob)
212
+      post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:scenario_ids => [scenarios(:jane_weather).id])
213
+      assigns(:agent).should have(1).errors_on(:scenarios)
214
+    end
215
+
161 216
     it "shows errors" do
162 217
       sign_in users(:bob)
163 218
       post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "")
164 219
       assigns(:agent).should have(1).errors_on(:name)
165 220
       response.should render_template("edit")
166 221
     end
222
+
223
+    describe "redirecting back" do
224
+      before do
225
+        sign_in users(:bob)
226
+      end
227
+
228
+      it "can redirect back to the show path" do
229
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "show"
230
+        response.should redirect_to(agent_path(agents(:bob_website_agent)))
231
+      end
232
+
233
+      it "redirect back to the index path by default" do
234
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name")
235
+        response.should redirect_to(agents_path)
236
+      end
237
+
238
+      it "accepts return paths to scenarios" do
239
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "/scenarios/2"
240
+        response.should redirect_to("/scenarios/2")
241
+      end
242
+
243
+      it "sanitizes return paths" do
244
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "/scenar"
245
+        response.should redirect_to(agents_path)
246
+
247
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "http://google.com"
248
+        response.should redirect_to(agents_path)
249
+
250
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "javascript:alert(1)"
251
+        response.should redirect_to(agents_path)
252
+      end
253
+    end
254
+  end
255
+
256
+  describe "PUT leave_scenario" do
257
+    it "removes an Agent from the given Scenario for the current user" do
258
+      sign_in users(:bob)
259
+
260
+      agents(:bob_weather_agent).scenarios.should include(scenarios(:bob_weather))
261
+      put :leave_scenario, :id => agents(:bob_weather_agent).to_param, :scenario_id => scenarios(:bob_weather).to_param
262
+      agents(:bob_weather_agent).scenarios.should_not include(scenarios(:bob_weather))
263
+
264
+      Scenario.where(:id => scenarios(:bob_weather).id).should exist
265
+
266
+      lambda {
267
+        put :leave_scenario, :id => agents(:jane_weather_agent).to_param, :scenario_id => scenarios(:jane_weather).to_param
268
+      }.should raise_error(ActiveRecord::RecordNotFound)
269
+    end
167 270
   end
168 271
 
169 272
   describe "DELETE destroy" do

+ 26 - 0
spec/controllers/scenario_imports_controller_spec.rb

@@ -0,0 +1,26 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioImportsController do
4
+  before do
5
+    sign_in users(:bob)
6
+  end
7
+
8
+  describe "GET new" do
9
+    it "initializes a new ScenarioImport and renders new" do
10
+      get :new
11
+      assigns(:scenario_import).should be_a(ScenarioImport)
12
+      response.should render_template(:new)
13
+    end
14
+  end
15
+
16
+  describe "POST create" do
17
+    it "initializes a ScenarioImport for current_user, passing in params" do
18
+      post :create, :scenario_import => { :url => "bad url" }
19
+      assigns(:scenario_import).user.should == users(:bob)
20
+      assigns(:scenario_import).url.should == "bad url"
21
+      assigns(:scenario_import).should_not be_valid
22
+      response.should render_template(:new)
23
+    end
24
+  end
25
+end
26
+

+ 152 - 0
spec/controllers/scenarios_controller_spec.rb

@@ -0,0 +1,152 @@
1
+require 'spec_helper'
2
+
3
+describe ScenariosController do
4
+  def valid_attributes(options = {})
5
+    { :name => "some_name" }.merge(options)
6
+  end
7
+
8
+  before do
9
+    sign_in users(:bob)
10
+  end
11
+
12
+  describe "GET index" do
13
+    it "only returns Scenarios for the current user" do
14
+      get :index
15
+      assigns(:scenarios).all? {|i| i.user.should == users(:bob) }.should be_true
16
+    end
17
+  end
18
+
19
+  describe "GET show" do
20
+    it "only shows Scenarios for the current user" do
21
+      get :show, :id => scenarios(:bob_weather).to_param
22
+      assigns(:scenario).should eq(scenarios(:bob_weather))
23
+
24
+      lambda {
25
+        get :show, :id => scenarios(:jane_weather).to_param
26
+      }.should raise_error(ActiveRecord::RecordNotFound)
27
+    end
28
+
29
+    it "loads Agents for the requested Scenario" do
30
+      get :show, :id => scenarios(:bob_weather).to_param
31
+      assigns(:agents).pluck(:id).should eq(scenarios(:bob_weather).agents.pluck(:id))
32
+    end
33
+  end
34
+
35
+  describe "GET share" do
36
+    it "only displays Scenario share information for the current user" do
37
+      get :share, :id => scenarios(:bob_weather).to_param
38
+      assigns(:scenario).should eq(scenarios(:bob_weather))
39
+
40
+      lambda {
41
+        get :share, :id => scenarios(:jane_weather).to_param
42
+      }.should raise_error(ActiveRecord::RecordNotFound)
43
+    end
44
+  end
45
+
46
+  describe "GET export" do
47
+    it "returns a JSON file download from an instantiated AgentsExporter" do
48
+      get :export, :id => scenarios(:bob_weather).to_param
49
+      assigns(:exporter).options[:name].should == scenarios(:bob_weather).name
50
+      assigns(:exporter).options[:description].should == scenarios(:bob_weather).description
51
+      assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents
52
+      assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid
53
+      assigns(:exporter).options[:source_url].should be_false
54
+      response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"'
55
+      response.headers['Content-Type'].should == 'application/json; charset=utf-8'
56
+      JSON.parse(response.body)["name"].should == scenarios(:bob_weather).name
57
+    end
58
+
59
+    it "only exports private Scenarios for the current user" do
60
+      get :export, :id => scenarios(:bob_weather).to_param
61
+      assigns(:scenario).should eq(scenarios(:bob_weather))
62
+
63
+      lambda {
64
+        get :export, :id => scenarios(:jane_weather).to_param
65
+      }.should raise_error(ActiveRecord::RecordNotFound)
66
+    end
67
+
68
+    describe "public exports" do
69
+      before do
70
+        scenarios(:jane_weather).update_attribute :public, true
71
+      end
72
+
73
+      it "exports public scenarios for other users when logged in" do
74
+        get :export, :id => scenarios(:jane_weather).to_param
75
+        assigns(:scenario).should eq(scenarios(:jane_weather))
76
+        assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
77
+      end
78
+
79
+      it "exports public scenarios for other users when logged out" do
80
+        sign_out :user
81
+        get :export, :id => scenarios(:jane_weather).to_param
82
+        assigns(:scenario).should eq(scenarios(:jane_weather))
83
+        assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
84
+      end
85
+    end
86
+  end
87
+
88
+  describe "GET edit" do
89
+    it "only shows Scenarios for the current user" do
90
+      get :edit, :id => scenarios(:bob_weather).to_param
91
+      assigns(:scenario).should eq(scenarios(:bob_weather))
92
+
93
+      lambda {
94
+        get :edit, :id => scenarios(:jane_weather).to_param
95
+      }.should raise_error(ActiveRecord::RecordNotFound)
96
+    end
97
+  end
98
+
99
+  describe "POST create" do
100
+    it "creates Scenarios for the current user" do
101
+      expect {
102
+        post :create, :scenario => valid_attributes
103
+      }.to change { users(:bob).scenarios.count }.by(1)
104
+    end
105
+
106
+    it "shows errors" do
107
+      expect {
108
+        post :create, :scenario => valid_attributes(:name => "")
109
+      }.not_to change { users(:bob).scenarios.count }
110
+      assigns(:scenario).should have(1).errors_on(:name)
111
+      response.should render_template("new")
112
+    end
113
+
114
+    it "will not create Scenarios for other users" do
115
+      expect {
116
+        post :create, :scenario => valid_attributes(:user_id => users(:jane).id)
117
+      }.to raise_error(ActiveModel::MassAssignmentSecurity::Error)
118
+    end
119
+  end
120
+
121
+  describe "PUT update" do
122
+    it "updates attributes on Scenarios for the current user" do
123
+      post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "new_name", :public => "1" }
124
+      response.should redirect_to(scenario_path(scenarios(:bob_weather)))
125
+      scenarios(:bob_weather).reload.name.should == "new_name"
126
+      scenarios(:bob_weather).should be_public
127
+
128
+      lambda {
129
+        post :update, :id => scenarios(:jane_weather).to_param, :scenario => { :name => "new_name" }
130
+      }.should raise_error(ActiveRecord::RecordNotFound)
131
+      scenarios(:jane_weather).reload.name.should_not == "new_name"
132
+    end
133
+
134
+    it "shows errors" do
135
+      post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "" }
136
+      assigns(:scenario).should have(1).errors_on(:name)
137
+      response.should render_template("edit")
138
+    end
139
+  end
140
+
141
+  describe "DELETE destroy" do
142
+    it "destroys only Scenarios owned by the current user" do
143
+      expect {
144
+        delete :destroy, :id => scenarios(:bob_weather).to_param
145
+      }.to change(Scenario, :count).by(-1)
146
+
147
+      lambda {
148
+        delete :destroy, :id => scenarios(:jane_weather).to_param
149
+      }.should raise_error(ActiveRecord::RecordNotFound)
150
+    end
151
+  end
152
+end

+ 8 - 0
spec/fixtures/agents.yml

@@ -4,6 +4,7 @@ jane_website_agent:
4 4
   events_count: 1
5 5
   schedule: "5pm"
6 6
   name: "ZKCD"
7
+  guid: <%= SecureRandom.hex %>
7 8
   options: <%= {
8 9
                  :url => "http://trailers.apple.com/trailers/home/rss/newtrailers.rss",
9 10
                  :expected_update_period_in_days => 2,
@@ -20,6 +21,7 @@ bob_website_agent:
20 21
   events_count: 1
21 22
   schedule: "midnight"
22 23
   name: "ZKCD"
24
+  guid: <%= SecureRandom.hex %>
23 25
   options: <%= {
24 26
                  :url => "http://xkcd.com",
25 27
                  :expected_update_period_in_days => 2,
@@ -35,6 +37,7 @@ bob_weather_agent:
35 37
   user: bob
36 38
   schedule: "midnight"
37 39
   name: "SF Weather"
40
+  guid: <%= SecureRandom.hex %>
38 41
   keep_events_for: 45
39 42
   options: <%= {
40 43
                  :location => 94102,
@@ -48,6 +51,7 @@ jane_weather_agent:
48 51
   user: jane
49 52
   schedule: "midnight"
50 53
   name: "SF Weather"
54
+  guid: <%= SecureRandom.hex %>
51 55
   keep_events_for: 30
52 56
   options: <%= {
53 57
                  :location => 94103,
@@ -60,6 +64,7 @@ jane_rain_notifier_agent:
60 64
   type: Agents::TriggerAgent
61 65
   user: jane
62 66
   name: "Jane's Rain Watcher"
67
+  guid: <%= SecureRandom.hex %>
63 68
   options: <%= {
64 69
                  :expected_receive_period_in_days => "2",
65 70
                  :rules => [{
@@ -74,6 +79,7 @@ bob_rain_notifier_agent:
74 79
   type: Agents::TriggerAgent
75 80
   user: bob
76 81
   name: "Bob's Rain Watcher"
82
+  guid: <%= SecureRandom.hex %>
77 83
   options: <%= {
78 84
                  :expected_receive_period_in_days => "2",
79 85
                  :rules => [{
@@ -88,6 +94,7 @@ bob_twitter_user_agent:
88 94
   type: Agents::TwitterUserAgent
89 95
   user: bob
90 96
   name: "Bob's Twitter User Watcher"
97
+  guid: <%= SecureRandom.hex %>
91 98
   options: <%= {
92 99
       :username => "tectonic",
93 100
       :expected_update_period_in_days => "2",
@@ -101,3 +108,4 @@ bob_manual_event_agent:
101 108
   type: Agents::ManualEventAgent
102 109
   user: bob
103 110
   name: "Bob's event testing agent"
111
+  guid: <%= SecureRandom.hex %>

+ 15 - 0
spec/fixtures/scenario_memberships.yml

@@ -0,0 +1,15 @@
1
+jane_weather_agent_scenario_membership:
2
+  agent: jane_weather_agent
3
+  scenario: jane_weather
4
+
5
+jane_rain_notifier_agent_scenario_membership:
6
+  agent: jane_rain_notifier_agent
7
+  scenario: jane_weather
8
+
9
+bob_weather_agent_scenario_membership:
10
+  agent: bob_weather_agent
11
+  scenario: bob_weather
12
+
13
+bob_rain_notifier_agent_scenario_membership:
14
+  agent: bob_rain_notifier_agent
15
+  scenario: bob_weather

+ 13 - 0
spec/fixtures/scenarios.yml

@@ -0,0 +1,13 @@
1
+jane_weather:
2
+  name: Jane's weather alert Scenario
3
+  user: jane
4
+  description: Jane's weather alert system
5
+  public: false
6
+  guid: random-guid-generated-by-bob
7
+
8
+bob_weather:
9
+  name: Bob's weather alert Scenario
10
+  user: bob
11
+  description: Bob's weather alert system
12
+  public: false
13
+  guid: random-guid-generated-by-jane

+ 3 - 1
spec/fixtures/users.yml

@@ -4,8 +4,10 @@ bob:
4 4
   email: "bob@example.com"
5 5
   username: bob
6 6
   invitation_code: <%= User::INVITATION_CODES.last %>
7
+  scenario_count: 1
7 8
 
8 9
 jane:
9 10
   email: "jane@example.com"
10 11
   username: jane
11
-  invitation_code: <%= User::INVITATION_CODES.last %>
12
+  invitation_code: <%= User::INVITATION_CODES.last %>
13
+  scenario_count: 1

+ 61 - 0
spec/lib/agents_exporter_spec.rb

@@ -0,0 +1,61 @@
1
+# encoding: utf-8
2
+
3
+require 'spec_helper'
4
+
5
+describe AgentsExporter do
6
+  describe "#as_json" do
7
+    let(:name) { "My set of Agents" }
8
+    let(:description) { "These Agents work together nicely!" }
9
+    let(:guid) { "some-guid" }
10
+    let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" }
11
+    let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] }
12
+    let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) }
13
+
14
+    it "outputs a structure containing name, description, the date, all agents & their links" do
15
+      data = exporter.as_json
16
+      data[:name].should == name
17
+      data[:description].should == description
18
+      data[:source_url].should == source_url
19
+      data[:guid].should == guid
20
+      Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
21
+      data[:links].should == [{ :source => 0, :receiver => 1 }]
22
+      data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
23
+      data[:agents].all? { |agent_json| agent_json[:guid].present? && agent_json[:type].present? && agent_json[:name].present? }.should be_true
24
+
25
+      data[:agents][0].should_not have_key(:propagate_immediately) # can't receive events
26
+      data[:agents][1].should_not have_key(:schedule) # can't be scheduled
27
+    end
28
+
29
+    it "does not output links to other agents outside of the incoming set" do
30
+      Link.create!(:source_id => agents(:jane_weather_agent).id, :receiver_id => agents(:jane_website_agent).id)
31
+      Link.create!(:source_id => agents(:jane_website_agent).id, :receiver_id => agents(:jane_rain_notifier_agent).id)
32
+
33
+      exporter.as_json[:links].should == [{ :source => 0, :receiver => 1 }]
34
+    end
35
+  end
36
+
37
+  describe "#filename" do
38
+    it "strips special characters" do
39
+      AgentsExporter.new(:name => "ƏfooƐƕƺbar").filename.should == "foo-bar.json"
40
+    end
41
+
42
+    it "strips punctuation" do
43
+      AgentsExporter.new(:name => "foo,bar").filename.should == "foo-bar.json"
44
+    end
45
+
46
+    it "strips leading and trailing dashes" do
47
+      AgentsExporter.new(:name => ",foo,").filename.should == "foo.json"
48
+    end
49
+
50
+    it "has a default when options[:name] is nil" do
51
+      AgentsExporter.new(:name => nil).filename.should == "exported-agents.json"
52
+    end
53
+
54
+    it "has a default when the result is empty" do
55
+      AgentsExporter.new(:name => "").filename.should == "exported-agents.json"
56
+      AgentsExporter.new(:name => "Ə").filename.should == "exported-agents.json"
57
+      AgentsExporter.new(:name => "-").filename.should == "exported-agents.json"
58
+      AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json"
59
+    end
60
+  end
61
+end

+ 24 - 1
spec/models/agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/working_helpers'
3 2
 
4 3
 describe Agent do
5 4
   it_behaves_like WorkingHelpers
@@ -123,7 +122,14 @@ describe Agent do
123 122
     end
124 123
 
125 124
     describe Agents::SomethingSource do
125
+      let(:new_instance) do
126
+        agent = Agents::SomethingSource.new(:name => "some agent")
127
+        agent.user = users(:bob)
128
+        agent
129
+      end
130
+
126 131
       it_behaves_like LiquidInterpolatable
132
+      it_behaves_like HasGuid
127 133
     end
128 134
 
129 135
     describe ".default_schedule" do
@@ -484,6 +490,23 @@ describe Agent do
484 490
         agent.should have(0).errors_on(:sources)
485 491
       end
486 492
 
493
+      it "should not allow scenarios owned by other people" do
494
+        agent = Agents::SomethingSource.new(:name => "something")
495
+        agent.user = users(:bob)
496
+
497
+        agent.scenario_ids = [scenarios(:bob_weather).id]
498
+        agent.should have(0).errors_on(:scenarios)
499
+
500
+        agent.scenario_ids = [scenarios(:bob_weather).id, scenarios(:jane_weather).id]
501
+        agent.should have(1).errors_on(:scenarios)
502
+
503
+        agent.scenario_ids = [scenarios(:jane_weather).id]
504
+        agent.should have(1).errors_on(:scenarios)
505
+
506
+        agent.user = users(:jane)
507
+        agent.should have(0).errors_on(:scenarios)
508
+      end
509
+
487 510
       it "validates keep_events_for" do
488 511
         agent = Agents::SomethingSource.new(:name => "something")
489 512
         agent.user = users(:bob)

+ 0 - 1
spec/models/agents/data_output_agent_spec.rb

@@ -1,7 +1,6 @@
1 1
 # encoding: utf-8
2 2
 
3 3
 require 'spec_helper'
4
-require 'models/concerns/liquid_interpolatable'
5 4
 
6 5
 describe Agents::DataOutputAgent do
7 6
   let(:agent) do

+ 0 - 1
spec/models/agents/hipchat_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::HipchatAgent do
5 4
   before(:each) do

+ 0 - 1
spec/models/agents/human_task_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::HumanTaskAgent do
5 4
   before do

+ 0 - 1
spec/models/agents/jabber_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::JabberAgent do
5 4
   let(:sent) { [] }

+ 0 - 1
spec/models/agents/peak_detector_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::PeakDetectorAgent do
5 4
   before do

+ 0 - 1
spec/models/agents/pushbullet_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::PushbulletAgent do
5 4
   before(:each) do

+ 2 - 2
spec/models/agents/slack_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::SlackAgent do
5 4
   before(:each) do
@@ -49,7 +48,8 @@ describe Agents::SlackAgent do
49 48
                        username: @event.payload[:username]
50 49
                       )
51 50
       end
52
-      expect(@checker.receive([@event])).to_not raise_error
51
+
52
+      lambda { @checker.receive([@event]) }.should_not raise_error
53 53
     end
54 54
   end
55 55
 

+ 0 - 2
spec/models/agents/translation_agent_spec.rb

@@ -1,6 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3
-
4 2
 
5 3
 describe Agents::TranslationAgent do
6 4
     before do

+ 0 - 1
spec/models/agents/trigger_agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::TriggerAgent do
5 4
   before do

+ 411 - 0
spec/models/scenario_import_spec.rb

@@ -0,0 +1,411 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioImport do
4
+  let(:user) { users(:bob) }
5
+  let(:guid) { "somescenarioguid" }
6
+  let(:description) { "This is a cool Huginn Scenario that does something useful!" }
7
+  let(:name) { "A useful Scenario" }
8
+  let(:source_url) { "http://example.com/scenarios/2/export.json" }
9
+  let(:weather_agent_options) {
10
+    {
11
+      'api_key' => 'some-api-key',
12
+      'location' => '12345'
13
+    }
14
+  }
15
+  let(:trigger_agent_options) {
16
+    {
17
+      'expected_receive_period_in_days' => 2,
18
+      'rules' => [{
19
+                    'type' => "regex",
20
+                    'value' => "rain|storm",
21
+                    'path' => "conditions",
22
+                  }],
23
+      'message' => "Looks like rain!"
24
+    }
25
+  }
26
+  let(:valid_parsed_weather_agent_data) do
27
+    {
28
+      :type => "Agents::WeatherAgent",
29
+      :name => "a weather agent",
30
+      :schedule => "5pm",
31
+      :keep_events_for => 14,
32
+      :disabled => true,
33
+      :guid => "a-weather-agent",
34
+      :options => weather_agent_options
35
+    }
36
+  end
37
+  let(:valid_parsed_trigger_agent_data) do
38
+    {
39
+      :type => "Agents::TriggerAgent",
40
+      :name => "listen for weather",
41
+      :keep_events_for => 0,
42
+      :propagate_immediately => true,
43
+      :disabled => false,
44
+      :guid => "a-trigger-agent",
45
+      :options => trigger_agent_options
46
+    }
47
+  end
48
+  let(:valid_parsed_data) do
49
+    { 
50
+      :name => name,
51
+      :description => description,
52
+      :guid => guid,
53
+      :source_url => source_url,
54
+      :exported_at => 2.days.ago.utc.iso8601,
55
+      :agents => [
56
+        valid_parsed_weather_agent_data,
57
+        valid_parsed_trigger_agent_data
58
+      ],
59
+      :links => [
60
+        { :source => 0, :receiver => 1 }
61
+      ]
62
+    }
63
+  end
64
+  let(:valid_data) { valid_parsed_data.to_json }
65
+  let(:invalid_data) { { :name => "some scenario missing a guid" }.to_json }
66
+
67
+  describe "initialization" do
68
+    it "is initialized with an attributes hash" do
69
+      ScenarioImport.new(:url => "http://google.com").url.should == "http://google.com"
70
+    end
71
+  end
72
+
73
+  describe "validations" do
74
+    subject do
75
+      _import = ScenarioImport.new
76
+      _import.set_user(user)
77
+      _import
78
+    end
79
+
80
+    it "is not valid when none of file, url, or data are present" do
81
+      subject.should_not be_valid
82
+      subject.should have(1).error_on(:base)
83
+      subject.errors[:base].should include("Please provide either a Scenario JSON File or a Public Scenario URL.")
84
+    end
85
+
86
+    describe "data" do
87
+      it "should be invalid with invalid data" do
88
+        subject.data = invalid_data
89
+        subject.should_not be_valid
90
+        subject.should have(1).error_on(:base)
91
+
92
+        subject.data = "foo"
93
+        subject.should_not be_valid
94
+        subject.should have(1).error_on(:base)
95
+
96
+        # It also clears the data when invalid
97
+        subject.data.should be_nil
98
+      end
99
+
100
+      it "should be valid with valid data" do
101
+        subject.data = valid_data
102
+        subject.should be_valid
103
+      end
104
+    end
105
+
106
+    describe "url" do
107
+      it "should be invalid with an unreasonable URL" do
108
+        subject.url = "foo"
109
+        subject.should_not be_valid
110
+        subject.should have(1).error_on(:url)
111
+        subject.errors[:url].should include("appears to be invalid")
112
+      end
113
+
114
+      it "should be invalid when the referenced url doesn't contain a scenario" do
115
+        stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => invalid_data)
116
+        subject.url = "http://example.com/scenarios/1/export.json"
117
+        subject.should_not be_valid
118
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
119
+      end
120
+
121
+      it "should be valid when the url points to a valid scenario" do
122
+        stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => valid_data)
123
+        subject.url = "http://example.com/scenarios/1/export.json"
124
+        subject.should be_valid
125
+      end
126
+    end
127
+
128
+    describe "file" do
129
+      it "should be invalid when the uploaded file doesn't contain a scenario" do
130
+        subject.file = StringIO.new("foo")
131
+        subject.should_not be_valid
132
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
133
+
134
+        subject.file = StringIO.new(invalid_data)
135
+        subject.should_not be_valid
136
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
137
+      end
138
+
139
+      it "should be valid with a valid uploaded scenario" do
140
+        subject.file = StringIO.new(valid_data)
141
+        subject.should be_valid
142
+      end
143
+    end
144
+  end
145
+  
146
+  describe "#dangerous?" do
147
+    it "returns false on most Agents" do
148
+      ScenarioImport.new(:data => valid_data).should_not be_dangerous
149
+    end
150
+
151
+    it "returns true if a ShellCommandAgent is present" do
152
+      valid_parsed_data[:agents][0][:type] = "Agents::ShellCommandAgent"
153
+      ScenarioImport.new(:data => valid_parsed_data.to_json).should be_dangerous
154
+    end
155
+  end
156
+
157
+  describe "#import and #generate_diff" do
158
+    let(:scenario_import) do
159
+      _import = ScenarioImport.new(:data => valid_data)
160
+      _import.set_user users(:bob)
161
+      _import
162
+    end
163
+
164
+    context "when this scenario has never been seen before" do
165
+      describe "#import" do
166
+        it "makes a new scenario" do
167
+          lambda {
168
+            scenario_import.import(:skip_agents => true)
169
+          }.should change { users(:bob).scenarios.count }.by(1)
170
+
171
+          scenario_import.scenario.name.should == name
172
+          scenario_import.scenario.description.should == description
173
+          scenario_import.scenario.guid.should == guid
174
+          scenario_import.scenario.source_url.should == source_url
175
+          scenario_import.scenario.public.should be_false
176
+        end
177
+
178
+        it "creates the Agents" do
179
+          lambda {
180
+            scenario_import.import
181
+          }.should change { users(:bob).agents.count }.by(2)
182
+
183
+          weather_agent = scenario_import.scenario.agents.find_by(:guid => "a-weather-agent")
184
+          trigger_agent = scenario_import.scenario.agents.find_by(:guid => "a-trigger-agent")
185
+
186
+          weather_agent.name.should == "a weather agent"
187
+          weather_agent.schedule.should == "5pm"
188
+          weather_agent.keep_events_for.should == 14
189
+          weather_agent.propagate_immediately.should be_false
190
+          weather_agent.should be_disabled
191
+          weather_agent.memory.should be_empty
192
+          weather_agent.options.should == weather_agent_options
193
+
194
+          trigger_agent.name.should == "listen for weather"
195
+          trigger_agent.sources.should == [weather_agent]
196
+          trigger_agent.schedule.should be_nil
197
+          trigger_agent.keep_events_for.should == 0
198
+          trigger_agent.propagate_immediately.should be_true
199
+          trigger_agent.should_not be_disabled
200
+          trigger_agent.memory.should be_empty
201
+          trigger_agent.options.should == trigger_agent_options
202
+        end
203
+
204
+        it "creates new Agents, even if one already exists with the given guid (so that we don't overwrite a user's work outside of the scenario)" do
205
+          agents(:bob_weather_agent).update_attribute :guid, "a-weather-agent"
206
+
207
+          lambda {
208
+            scenario_import.import
209
+          }.should change { users(:bob).agents.count }.by(2)
210
+        end
211
+      end
212
+
213
+      describe "#generate_diff" do
214
+        it "returns AgentDiff objects for the incoming Agents" do
215
+          scenario_import.should be_valid
216
+
217
+          agent_diffs = scenario_import.agent_diffs
218
+
219
+          weather_agent_diff = agent_diffs[0]
220
+          trigger_agent_diff = agent_diffs[1]
221
+
222
+          valid_parsed_weather_agent_data.each do |key, value|
223
+            if key == :type
224
+              value = value.split("::").last
225
+            end
226
+            weather_agent_diff.should respond_to(key)
227
+            field = weather_agent_diff.send(key)
228
+            field.should be_a(ScenarioImport::AgentDiff::FieldDiff)
229
+            field.incoming.should == value
230
+            field.updated.should == value
231
+            field.current.should be_nil
232
+          end
233
+          weather_agent_diff.should_not respond_to(:propagate_immediately)
234
+
235
+          valid_parsed_trigger_agent_data.each do |key, value|
236
+            if key == :type
237
+              value = value.split("::").last
238
+            end
239
+            trigger_agent_diff.should respond_to(key)
240
+            field = trigger_agent_diff.send(key)
241
+            field.should be_a(ScenarioImport::AgentDiff::FieldDiff)
242
+            field.incoming.should == value
243
+            field.updated.should == value
244
+            field.current.should be_nil
245
+          end
246
+          trigger_agent_diff.should_not respond_to(:schedule)
247
+        end
248
+      end
249
+    end
250
+
251
+    context "when an a scenario already exists with the given guid" do
252
+      let!(:existing_scenario) do
253
+        _existing_scenerio = users(:bob).scenarios.build(:name => "an existing scenario", :description => "something")
254
+        _existing_scenerio.guid = guid
255
+        _existing_scenerio.save!
256
+
257
+        agents(:bob_weather_agent).update_attribute :guid, "a-weather-agent"
258
+        agents(:bob_weather_agent).scenarios << _existing_scenerio
259
+
260
+        _existing_scenerio
261
+      end
262
+
263
+      describe "#import" do
264
+        it "uses the existing scenario, updating its data" do
265
+          lambda {
266
+            scenario_import.import(:skip_agents => true)
267
+            scenario_import.scenario.should == existing_scenario
268
+          }.should_not change { users(:bob).scenarios.count }
269
+
270
+          existing_scenario.reload
271
+          existing_scenario.guid.should == guid
272
+          existing_scenario.description.should == description
273
+          existing_scenario.name.should == name
274
+          existing_scenario.source_url.should == source_url
275
+          existing_scenario.public.should be_false
276
+        end
277
+
278
+        it "updates any existing agents in the scenario, and makes new ones as needed" do
279
+          scenario_import.should be_valid
280
+
281
+          lambda {
282
+            scenario_import.import
283
+          }.should change { users(:bob).agents.count }.by(1) # One, because the weather agent already existed.
284
+
285
+          weather_agent = existing_scenario.agents.find_by(:guid => "a-weather-agent")
286
+          trigger_agent = existing_scenario.agents.find_by(:guid => "a-trigger-agent")
287
+
288
+          weather_agent.should == agents(:bob_weather_agent)
289
+
290
+          weather_agent.name.should == "a weather agent"
291
+          weather_agent.schedule.should == "5pm"
292
+          weather_agent.keep_events_for.should == 14
293
+          weather_agent.propagate_immediately.should be_false
294
+          weather_agent.should be_disabled
295
+          weather_agent.memory.should be_empty
296
+          weather_agent.options.should == weather_agent_options
297
+
298
+          trigger_agent.name.should == "listen for weather"
299
+          trigger_agent.sources.should == [weather_agent]
300
+          trigger_agent.schedule.should be_nil
301
+          trigger_agent.keep_events_for.should == 0
302
+          trigger_agent.propagate_immediately.should be_true
303
+          trigger_agent.should_not be_disabled
304
+          trigger_agent.memory.should be_empty
305
+          trigger_agent.options.should == trigger_agent_options
306
+        end
307
+
308
+        it "honors updates coming from the UI" do
309
+          scenario_import.merges = {
310
+            "0" => {
311
+              "name" => "updated name",
312
+              "schedule" => "6pm",
313
+              "keep_events_for" => "2",
314
+              "disabled" => "false",
315
+              "options" => weather_agent_options.merge("api_key" => "foo").to_json
316
+            }
317
+          }
318
+
319
+          scenario_import.should be_valid
320
+
321
+          scenario_import.import.should be_true
322
+
323
+          weather_agent = existing_scenario.agents.find_by(:guid => "a-weather-agent")
324
+          weather_agent.name.should == "updated name"
325
+          weather_agent.schedule.should == "6pm"
326
+          weather_agent.keep_events_for.should == 2
327
+          weather_agent.should_not be_disabled
328
+          weather_agent.options.should == weather_agent_options.merge("api_key" => "foo")
329
+        end
330
+
331
+        it "adds errors when updated agents are invalid" do
332
+          scenario_import.merges = {
333
+            "0" => {
334
+              "name" => "",
335
+              "schedule" => "foo",
336
+              "keep_events_for" => "2",
337
+              "options" => weather_agent_options.merge("api_key" => "").to_json
338
+            }
339
+          }
340
+
341
+          scenario_import.import.should be_false
342
+
343
+          errors = scenario_import.errors.full_messages.to_sentence
344
+          errors.should =~ /Name can't be blank/
345
+          errors.should =~ /api_key is required/
346
+          errors.should =~ /Schedule is not a valid schedule/
347
+        end
348
+      end
349
+
350
+      describe "#generate_diff" do
351
+        it "returns AgentDiff objects that include 'current' values from any agents that already exist" do
352
+          agent_diffs = scenario_import.agent_diffs
353
+          weather_agent_diff = agent_diffs[0]
354
+          trigger_agent_diff = agent_diffs[1]
355
+
356
+          # Already exists
357
+          weather_agent_diff.agent.should == agents(:bob_weather_agent)
358
+          valid_parsed_weather_agent_data.each do |key, value|
359
+            next if key == :type
360
+            weather_agent_diff.send(key).current.should == agents(:bob_weather_agent).send(key)
361
+          end
362
+
363
+          # Doesn't exist yet
364
+          valid_parsed_trigger_agent_data.each do |key, value|
365
+            trigger_agent_diff.send(key).current.should be_nil
366
+          end
367
+        end
368
+
369
+        it "sets the 'updated' FieldDiff values based on any feedback from the user" do
370
+          scenario_import.merges = {
371
+            "0" => {
372
+              "name" => "a new name",
373
+              "schedule" => "6pm",
374
+              "keep_events_for" => "2",
375
+              "disabled" => "true",
376
+              "options" => weather_agent_options.merge("api_key" => "foo").to_json
377
+            },
378
+            "1" => {
379
+              "name" => "another new name"
380
+            }
381
+          }
382
+
383
+          scenario_import.should be_valid
384
+
385
+          agent_diffs = scenario_import.agent_diffs
386
+          weather_agent_diff = agent_diffs[0]
387
+          trigger_agent_diff = agent_diffs[1]
388
+
389
+          weather_agent_diff.name.current.should == agents(:bob_weather_agent).name
390
+          weather_agent_diff.name.incoming.should == valid_parsed_weather_agent_data[:name]
391
+          weather_agent_diff.name.updated.should == "a new name"
392
+
393
+          weather_agent_diff.schedule.updated.should == "6pm"
394
+          weather_agent_diff.keep_events_for.updated.should == "2"
395
+          weather_agent_diff.disabled.updated.should == "true"
396
+          weather_agent_diff.options.updated.should == weather_agent_options.merge("api_key" => "foo")
397
+        end
398
+
399
+        it "adds errors on validation when updated options are unparsable" do
400
+          scenario_import.merges = {
401
+            "0" => {
402
+              "options" => '{'
403
+            }
404
+          }
405
+          scenario_import.should_not be_valid
406
+          scenario_import.should have(1).error_on(:base)
407
+        end
408
+      end
409
+    end
410
+  end
411
+end

+ 43 - 0
spec/models/scenario_spec.rb

@@ -0,0 +1,43 @@
1
+require 'spec_helper'
2
+
3
+describe Scenario do
4
+  let(:new_instance) { users(:bob).scenarios.build(:name => "some scenario") }
5
+
6
+  it_behaves_like HasGuid
7
+
8
+  describe "validations" do
9
+    before do
10
+      new_instance.should be_valid
11
+    end
12
+
13
+    it "validates the presence of name" do
14
+      new_instance.name = ''
15
+      new_instance.should_not be_valid
16
+    end
17
+
18
+    it "validates the presence of user" do
19
+      new_instance.user = nil
20
+      new_instance.should_not be_valid
21
+    end
22
+
23
+    it "only allows Agents owned by user" do
24
+      new_instance.agent_ids = [agents(:bob_website_agent).id]
25
+      new_instance.should be_valid
26
+
27
+      new_instance.agent_ids = [agents(:jane_website_agent).id]
28
+      new_instance.should_not be_valid
29
+    end
30
+  end
31
+
32
+  describe "counters" do
33
+    it "maintains a counter cache on user" do
34
+      lambda {
35
+        new_instance.save!
36
+      }.should change { users(:bob).reload.scenario_count }.by(1)
37
+
38
+      lambda {
39
+        new_instance.destroy
40
+      }.should change { users(:bob).reload.scenario_count }.by(-1)
41
+    end
42
+  end
43
+end

+ 12 - 0
spec/support/shared_examples/has_guid.rb

@@ -0,0 +1,12 @@
1
+require 'spec_helper'
2
+
3
+shared_examples_for HasGuid do
4
+  it "gets created before_save, but only if it's not present" do
5
+    instance = new_instance
6
+    instance.guid.should be_nil
7
+    instance.save!
8
+    instance.guid.should_not be_nil
9
+
10
+    lambda { instance.save! }.should_not change { instance.reload.guid }
11
+  end
12
+end

spec/models/concerns/liquid_interpolatable.rb → spec/support/shared_examples/liquid_interpolatable.rb


+ 3 - 3
spec/models/concerns/working_helpers.rb

@@ -3,7 +3,7 @@ require 'spec_helper'
3 3
 shared_examples_for WorkingHelpers do
4 4
   describe "recent_error_logs?" do
5 5
     it "returns true if last_error_log_at is near last_event_at" do
6
-      agent = Agent.new
6
+      agent = described_class.new
7 7
 
8 8
       agent.last_error_log_at = 10.minutes.ago
9 9
       agent.last_event_at = 10.minutes.ago
@@ -26,9 +26,10 @@ shared_examples_for WorkingHelpers do
26 26
       agent.recent_error_logs?.should be_false
27 27
     end
28 28
   end
29
+
29 30
   describe "received_event_without_error?" do
30 31
     before do
31
-      @agent = Agent.new
32
+      @agent = described_class.new
32 33
     end
33 34
 
34 35
     it "should return false until the first event was received" do
@@ -49,5 +50,4 @@ shared_examples_for WorkingHelpers do
49 50
       @agent.received_event_without_error?.should == true
50 51
     end
51 52
   end
52
-
53 53
 end

+ 514 - 489
vendor/assets/javascripts/jquery.json-editor.js

@@ -1,5 +1,5 @@
1 1
 /*
2
-  Copyright (c) 2013, Andrew Cantino
2
+  Copyright (c) 2014, Andrew Cantino
3 3
   Copyright (c) 2009, Andrew Cantino & Kyle Maxwell
4 4
 
5 5
   Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,8 +23,8 @@
23 23
 
24 24
 
25 25
 
26
-  You will probably need to tell the editor where to find its add and delete images.  In your
27
-  code before you make the editor, do something like this:
26
+  You will probably need to tell the editor where to find its 'add' and 'delete' images.  In your
27
+  code, before you make the editor, do something like this:
28 28
      JSONEditor.prototype.ADD_IMG = '/javascripts/jsoneditor/add.png';
29 29
      JSONEditor.prototype.DELETE_IMG = '/javascripts/jsoneditor/delete.png';
30 30
 
@@ -36,504 +36,529 @@
36 36
 */
37 37
 
38 38
 
39
-function JSONEditorBase(options) {
40
-  if (!options) options = {};
41
-  this.builderShowing = true;
42
-  this.ADD_IMG = options.ADD_IMG || 'lib/images/add.png';
43
-  this.DELETE_IMG = options.DELETE_IMG || 'lib/images/delete.png';
44
-  this.functionButtonsEnabled = false;
45
-  this._doTruncation = true;
46
-  this._showWipe = options.showWipe;
47
-}
48
-
49
-function JSONEditor(wrapped, width, height) {
50
-  this.history = [];
51
-  this.historyPointer = -1;
52
-  if (wrapped == null || (wrapped.get && wrapped.get(0) == null)) throw "Must provide an element to wrap.";
53
-  var width = width || 600;
54
-  var height = height || 300;
55
-  this.wrapped = $(wrapped);
56
-
57
-  this.wrapped.wrap('<div class="json-editor"></div>');
58
-  this.container = $(this.wrapped.parent());
59
-  this.container.width(width).height(height);
60
-  this.wrapped.width(width).height(height);
61
-  this.wrapped.hide();
62
-  this.container.css("position", "relative");
63
-  this.doAutoFocus = false;
64
-  this.editingUnfocused();
65
-
66
-  this.rebuild();
67
-  var self = this;
68
-  this.container.focus(function(){
69
-  	$(this).children('textarea').height(self.container.height() - self.functionButtons.height() - 5);
70
-  	$(this).children('.builder').height(self.container.height() - self.functionButtons.height() - 10);
71
-  });
72
-
73
-  return this;
74
-}
75
-JSONEditor.prototype = new JSONEditorBase();
76
-
77
-JSONEditor.prototype.braceUI = function(key, struct) {
78
-  var self = this;
79
-  return $('<a class="icon" href="#"><strong>{</strong></a>').click(function(e) {
80
-    struct[key] = { "??": struct[key] };
81
-    self.doAutoFocus = true;
82
-    self.rebuild();
83
-    return false;
84
-  });
85
-};
86
-
87
-JSONEditor.prototype.bracketUI = function(key, struct) {
88
-  var self = this;
89
-  return $('<a class="icon" href="#"><strong>[</a>').click(function(e) {
90
-    struct[key] = [ struct[key] ];
91
-    self.doAutoFocus = true;
92
-    self.rebuild();
93
-    return false;
94
-  });
95
-};
96
-
97
-JSONEditor.prototype.deleteUI = function(key, struct, fullDelete) {
98
-  var self = this;
99
-  return $('<a class="icon" href="#" title="delete"><img src="' + this.DELETE_IMG + '" border=0/></a>').click(function(e) {
100
-    if (!fullDelete) {
101
-      var didSomething = false;
102
-      if (struct[key] instanceof Array) {
103
-        if(struct[key].length > 0) {
104
-          struct[key] = struct[key][0];
105
-          didSomething = true;
39
+(function() {
40
+
41
+  window.JSONEditor = (function() {
42
+
43
+    function JSONEditor(wrapped, options) {
44
+      if (options == null) {
45
+        options = {};
46
+      }
47
+      this.builderShowing = true;
48
+      this.ADD_IMG || (this.ADD_IMG = options.ADD_IMG || 'lib/images/add.png');
49
+      this.DELETE_IMG || (this.DELETE_IMG = options.DELETE_IMG || 'lib/images/delete.png');
50
+      this.functionButtonsEnabled = false;
51
+      this._doTruncation = true;
52
+      this._showWipe = options.showWipe;
53
+      this.history = [];
54
+      this.historyPointer = -1;
55
+      if (wrapped === null || (wrapped.get && wrapped.get(0) === null)) {
56
+        throw "Must provide an element to wrap.";
57
+      }
58
+      this.wrapped = $(wrapped);
59
+      this.wrapped.wrap('<div class="json-editor"></div>');
60
+      this.container = $(this.wrapped.parent());
61
+      this.wrapped.hide();
62
+      this.container.css("position", "relative");
63
+      this.doAutoFocus = false;
64
+      this.editingUnfocused();
65
+      this.rebuild();
66
+    }
67
+
68
+    JSONEditor.prototype.braceUI = function(key, struct) {
69
+      var _this = this;
70
+      return $('<a class="icon" href="#"><strong>{</strong></a>').click(function(e) {
71
+        e.preventDefault();
72
+        struct[key] = {
73
+          "??": struct[key]
74
+        };
75
+        _this.doAutoFocus = true;
76
+        return _this.rebuild();
77
+      });
78
+    };
79
+
80
+    JSONEditor.prototype.bracketUI = function(key, struct) {
81
+      var _this = this;
82
+      return $('<a class="icon" href="#"><strong>[</a>').click(function(e) {
83
+        e.preventDefault();
84
+        struct[key] = [struct[key]];
85
+        _this.doAutoFocus = true;
86
+        return _this.rebuild();
87
+      });
88
+    };
89
+
90
+    JSONEditor.prototype.deleteUI = function(key, struct, fullDelete) {
91
+      var _this = this;
92
+      return $("<a class='icon' href='#' title='delete'><img src='" + this.DELETE_IMG + "' border=0 /></a>").click(function(e) {
93
+        var didSomething, subkey, subval, _ref;
94
+        e.preventDefault();
95
+        if (!fullDelete) {
96
+          didSomething = false;
97
+          if (struct[key] instanceof Array) {
98
+            if (struct[key].length > 0) {
99
+              struct[key] = struct[key][0];
100
+              didSomething = true;
101
+            }
102
+          } else if (struct[key] instanceof Object) {
103
+            _ref = struct[key];
104
+            for (subkey in _ref) {
105
+              subval = _ref[subkey];
106
+              struct[key] = struct[key][subkey];
107
+              didSomething = true;
108
+              break;
109
+            }
110
+          }
111
+          if (didSomething) {
112
+            _this.rebuild();
113
+            return;
114
+          }
106 115
         }
107
-      } else if (struct[key] instanceof Object) {
108
-        for (var i in struct[key]) {
109
-          struct[key] = struct[key][i];
110
-          didSomething = true;
111
-          break;
116
+        if (struct instanceof Array) {
117
+          struct.splice(key, 1);
118
+        } else {
119
+          delete struct[key];
120
+        }
121
+        return _this.rebuild();
122
+      });
123
+    };
124
+
125
+    JSONEditor.prototype.wipeUI = function(key, struct) {
126
+      var _this = this;
127
+      return $('<a class="icon" href="#" title="wipe"><strong>W</strong></a>').click(function(e) {
128
+        e.preventDefault();
129
+        if (struct instanceof Array) {
130
+          struct.splice(key, 1);
131
+        } else {
132
+          delete struct[key];
133
+        }
134
+        return _this.rebuild();
135
+      });
136
+    };
137
+
138
+    JSONEditor.prototype.addUI = function(struct) {
139
+      var _this = this;
140
+      return $("<a class='icon' href='#' title='add'><img src='" + this.ADD_IMG + "' border=0/></a>").click(function(e) {
141
+        e.preventDefault();
142
+        if (struct instanceof Array) {
143
+          struct.push('??');
144
+        } else {
145
+          struct['??'] = '??';
146
+        }
147
+        _this.doAutoFocus = true;
148
+        return _this.rebuild();
149
+      });
150
+    };
151
+
152
+    JSONEditor.prototype.undo = function() {
153
+      if (this.saveStateIfTextChanged()) {
154
+        if (this.historyPointer > 0) {
155
+          this.historyPointer -= 1;
156
+        }
157
+        return this.restore();
158
+      }
159
+    };
160
+
161
+    JSONEditor.prototype.redo = function() {
162
+      if (this.historyPointer + 1 < this.history.length) {
163
+        if (this.saveStateIfTextChanged()) {
164
+          this.historyPointer += 1;
165
+          return this.restore();
112 166
         }
113 167
       }
114
-      if (didSomething) {
115
-        self.rebuild();
168
+    };
169
+
170
+    JSONEditor.prototype.showBuilder = function() {
171
+      if (this.checkJsonInText()) {
172
+        this.setJsonFromText();
173
+        this.rebuild();
174
+        this.wrapped.hide();
175
+        this.builder.show();
176
+        return true;
177
+      } else {
178
+        alert("Sorry, there appears to be an error in your JSON input.  Please fix it before continuing.");
116 179
         return false;
117 180
       }
118
-    }
119
-    if (struct instanceof Array) {
120
-      struct.splice(key, 1);
121
-    } else {
122
-      delete struct[key];
123
-    }
124
-    self.rebuild();
125
-    return false;
126
-  });
127
-};
128
-
129
-JSONEditor.prototype.wipeUI = function(key, struct) {
130
-  var self = this;
131
-  return $('<a class="icon" href="#" title="wipe"><strong>W</strong></a>').click(function(e) {
132
-    if (struct instanceof Array) {
133
-      struct.splice(key, 1);
134
-    } else {
135
-      delete struct[key];
136
-    }
137
-    self.rebuild();
138
-    return false;
139
-  });
140
-};
141
-
142
-JSONEditor.prototype.addUI = function(struct) {
143
-  var self = this;
144
-  return $('<a class="icon" href="#" title="add"><img src="' + this.ADD_IMG + '" border=0/></a>').click(function(e) {
145
-    if (struct instanceof Array) {
146
-      struct.push('??');
147
-    } else {
148
-      struct['??'] = '??';
149
-    }
150
-    self.doAutoFocus = true;
151
-    self.rebuild();
152
-    return false;
153
-  });
154
-};
155
-
156
-JSONEditor.prototype.undo = function() {
157
-  if (this.saveStateIfTextChanged()) {
158
-    if (this.historyPointer > 0) this.historyPointer -= 1;
159
-    this.restore();
160
-  }
161
-};
162
-
163
-JSONEditor.prototype.redo = function() {
164
-  if (this.historyPointer + 1 < this.history.length) {
165
-    if (this.saveStateIfTextChanged()) {
166
-      this.historyPointer += 1;
167
-      this.restore();
168
-    }
169
-  }
170
-};
171
-
172
-JSONEditor.prototype.showBuilder = function() {
173
-  if (this.checkJsonInText()) {
174
-    this.setJsonFromText();
175
-    this.rebuild();
176
-    this.wrapped.hide();
177
-    this.builder.show();
178
-    return true;
179
-  } else {
180
-    alert("Sorry, there appears to be an error in your JSON input.  Please fix it before continuing.");
181
-    return false;
182
-  }
183
-};
184
-
185
-JSONEditor.prototype.showText = function() {
186
-  this.builder.hide();
187
-  this.wrapped.show();
188
-};
189
-
190
-JSONEditor.prototype.toggleBuilder = function() {
191
-    if(this.builderShowing){
192
-      this.showText();
193
-      this.builderShowing = !this.builderShowing;
194
-    } else {
195
-      if (this.showBuilder()) {
196
-        this.builderShowing = !this.builderShowing;
181
+    };
182
+
183
+    JSONEditor.prototype.showText = function() {
184
+      this.builder.hide();
185
+      return this.wrapped.show();
186
+    };
187
+
188
+    JSONEditor.prototype.toggleBuilder = function() {
189
+      if (this.builderShowing) {
190
+        this.showText();
191
+        return this.builderShowing = !this.builderShowing;
192
+      } else {
193
+        if (this.showBuilder()) {
194
+          return this.builderShowing = !this.builderShowing;
195
+        }
197 196
       }
198
-    }
199
-};
200
-
201
-JSONEditor.prototype.showFunctionButtons = function(insider) {
202
-  if (!insider) this.functionButtonsEnabled = true;
203
-  if (this.functionButtonsEnabled) if (!this.functionButtons) {
204
-    this.functionButtons = $('<div class="function_buttons"></div>');
205
-    var self = this;
206
-    this.functionButtons.append($('<a href="#" style="padding-right: 10px;"></a>').click(function() {
207
-      self.undo();
208
-      return false;
209
-    }).text('Undo')).append($('<a href="#" style="padding-right: 10px;"></a>').click(function() {
210
-      self.redo();
211
-      return false;
212
-    }).text('Redo')).append($('<a id="toggle_view" href="#" style="padding-right: 10px;"></a>').click(function() {
213
-      self.toggleBuilder();
214
-      return false;
215
-    }).text('Toggle View'));
216
-    this.container.prepend(this.functionButtons);
217
-    this.container.height(this.container.height() + this.functionButtons.height() + 5);
218
-  }
219
-  if (this.functionButtons) {
220
-    this.wrapped.css('top', this.functionButtons.height() + 5 + 'px');
221
-    this.builder.css('top', this.functionButtons.height() + 5 + 'px');
222
-  }
223
-};
224
-
225
-JSONEditor.prototype.saveStateIfTextChanged = function() {
226
-  if (JSON.stringify(this.json, null, 2) != this.wrapped.get(0).value) {
227
-    if (this.checkJsonInText()) {
228
-      this.saveState(true);
229
-    } else {
230
-      if (confirm("The current JSON is malformed.  If you continue, the current JSON will not be saved.  Do you wish to continue?")) {
231
-        this.historyPointer += 1;
197
+    };
198
+
199
+    JSONEditor.prototype.showFunctionButtons = function(insider) {
200
+      var _this = this;
201
+      if (!insider) {
202
+        this.functionButtonsEnabled = true;
203
+      }
204
+      if (this.functionButtonsEnabled && !this.functionButtons) {
205
+        this.functionButtons = $('<div class="function_buttons"></div>');
206
+        this.functionButtons.append($('<a href="#" style="padding-right: 10px;">Undo</a>').click(function(e) {
207
+          e.preventDefault();
208
+          return _this.undo();
209
+        }));
210
+        this.functionButtons.append($('<a href="#" style="padding-right: 10px;">Redo</a>').click(function(e) {
211
+          e.preventDefault();
212
+          return _this.redo();
213
+        }));
214
+        this.functionButtons.append($('<a id="toggle_view" href="#" style="padding-right: 10px; float: right;">Toggle View</a>').click(function(e) {
215
+          e.preventDefault();
216
+          return _this.toggleBuilder();
217
+        }));
218
+        return this.container.prepend(this.functionButtons);
219
+      }
220
+    };
221
+
222
+    JSONEditor.prototype.saveStateIfTextChanged = function() {
223
+      if (JSON.stringify(this.json, null, 2) !== this.wrapped.get(0).value) {
224
+        if (this.checkJsonInText()) {
225
+          this.saveState(true);
226
+        } else {
227
+          if (confirm("The current JSON is malformed.  If you continue, the current JSON will not be saved.  Do you wish to continue?")) {
228
+            this.historyPointer += 1;
229
+            true;
230
+          } else {
231
+            false;
232
+          }
233
+        }
234
+      }
235
+      return true;
236
+    };
237
+
238
+    JSONEditor.prototype.restore = function() {
239
+      if (this.history[this.historyPointer]) {
240
+        this.wrapped.get(0).value = this.history[this.historyPointer];
241
+        return this.rebuild(true);
242
+      }
243
+    };
244
+
245
+    JSONEditor.prototype.saveState = function(skipStoreText) {
246
+      var text;
247
+      if (this.json) {
248
+        if (!skipStoreText) {
249
+          this.storeToText();
250
+        }
251
+        text = this.wrapped.get(0).value;
252
+        if (this.history[this.historyPointer] !== text) {
253
+          this.historyTruncate();
254
+          this.history.push(text);
255
+          return this.historyPointer += 1;
256
+        }
257
+      }
258
+    };
259
+
260
+    JSONEditor.prototype.fireChange = function() {
261
+      return $(this.wrapped).trigger('change');
262
+    };
263
+
264
+    JSONEditor.prototype.historyTruncate = function() {
265
+      if (this.historyPointer + 1 < this.history.length) {
266
+        return this.history.splice(this.historyPointer + 1, this.history.length - this.historyPointer);
267
+      }
268
+    };
269
+
270
+    JSONEditor.prototype.storeToText = function() {
271
+      return this.wrapped.get(0).value = JSON.stringify(this.json, null, 2);
272
+    };
273
+
274
+    JSONEditor.prototype.getJSONText = function() {
275
+      this.rebuild();
276
+      return this.wrapped.get(0).value;
277
+    };
278
+
279
+    JSONEditor.prototype.getJSON = function() {
280
+      this.rebuild();
281
+      return this.json;
282
+    };
283
+
284
+    JSONEditor.prototype.rebuild = function(doNotRefreshText) {
285
+      var changed, elem;
286
+      if (!this.json) {
287
+        this.setJsonFromText();
288
+      }
289
+      changed = this.haveThingsChanged();
290
+      if (this.json && !doNotRefreshText) {
291
+        this.saveState();
292
+      }
293
+      this.cleanBuilder();
294
+      this.setJsonFromText();
295
+      this.alreadyFocused = false;
296
+      elem = this.build(this.json, this.builder, null, null, this.json);
297
+      this.recoverScrollPosition();
298
+      if (elem && elem.text() === '??' && !this.alreadyFocused && this.doAutoFocus) {
299
+        this.alreadyFocused = true;
300
+        this.doAutoFocus = false;
301
+        elem = elem.find('.editable');
302
+        elem.click();
303
+        elem.find('input').focus().select();
304
+      }
305
+      if (changed) {
306
+        return this.fireChange();
307
+      }
308
+    };
309
+
310
+    JSONEditor.prototype.haveThingsChanged = function() {
311
+      return this.json && JSON.stringify(this.json, null, 2) !== this.wrapped.get(0).value;
312
+    };
313
+
314
+    JSONEditor.prototype.saveScrollPosition = function() {
315
+      return this.oldScrollHeight = this.builder.scrollTop();
316
+    };
317
+
318
+    JSONEditor.prototype.recoverScrollPosition = function() {
319
+      return this.builder.scrollTop(this.oldScrollHeight);
320
+    };
321
+
322
+    JSONEditor.prototype.setJsonFromText = function() {
323
+      if (this.wrapped.get(0).value.length === 0) {
324
+        this.wrapped.get(0).value = "{}";
325
+      }
326
+      try {
327
+        this.wrapped.get(0).value = this.wrapped.get(0).value.replace(/((^|[^\\])(\\\\)*)\\n/g, '$1\\\\n').replace(/((^|[^\\])(\\\\)*)\\t/g, '$1\\\\t');
328
+        return this.json = JSON.parse(this.wrapped.get(0).value);
329
+      } catch (e) {
330
+        return alert("Got bad JSON from text.");
331
+      }
332
+    };
333
+
334
+    JSONEditor.prototype.checkJsonInText = function() {
335
+      try {
336
+        JSON.parse(this.wrapped.get(0).value);
232 337
         return true;
233
-      } else {
338
+      } catch (e) {
234 339
         return false;
235 340
       }
236
-    }
237
-  }
238
-  return true;
239
-};
240
-
241
-JSONEditor.prototype.restore = function() {
242
-  if (this.history[this.historyPointer]) {
243
-    this.wrapped.get(0).value = this.history[this.historyPointer];
244
-    this.rebuild(true);
245
-  }
246
-};
247
-
248
-JSONEditor.prototype.saveState = function(skipStoreText) {
249
-  if (this.json) {
250
-    if (!skipStoreText) this.storeToText();
251
-    var text = this.wrapped.get(0).value;
252
-    if (this.history[this.historyPointer] != text) {
253
-      this.historyTruncate();
254
-      this.history.push(text);
255
-      this.historyPointer += 1;
256
-    }
257
-  }
258
-};
259
-
260
-JSONEditor.prototype.fireChange = function() {
261
-  $(this.wrapped).trigger('change');
262
-};
263
-
264
-JSONEditor.prototype.historyTruncate = function() {
265
-  if (this.historyPointer + 1 < this.history.length) {
266
-    this.history.splice(this.historyPointer + 1, this.history.length - this.historyPointer);
267
-  }
268
-};
269
-
270
-JSONEditor.prototype.storeToText = function() {
271
-  this.wrapped.get(0).value = JSON.stringify(this.json, null, 2);
272
-};
273
-
274
-JSONEditor.prototype.getJSONText = function() {
275
-  this.rebuild();
276
-  return this.wrapped.get(0).value;
277
-};
278
-
279
-JSONEditor.prototype.getJSON = function() {
280
-  this.rebuild();
281
-  return this.json;
282
-};
283
-
284
-JSONEditor.prototype.rebuild = function(doNotRefreshText) {
285
-  if (!this.json) this.setJsonFromText();
286
-  var changed = this.haveThingsChanged();
287
-  if (this.json && !doNotRefreshText) {
288
-    this.saveState();
289
-  }
290
-  this.cleanBuilder();
291
-  this.setJsonFromText();
292
-  this.alreadyFocused = false;
293
-  var elem = this.build(this.json, this.builder, null, null, this.json);
294
-
295
-  this.recoverScrollPosition();
296
-
297
-  // Auto-focus to edit '??' keys and values.
298
-  if (elem) if (elem.text() == '??' && !this.alreadyFocused && this.doAutoFocus) {
299
-    this.alreadyFocused = true;
300
-    this.doAutoFocus = false;
301
-
302
-    elem = elem.find('.editable');
303
-    elem.click();
304
-    elem.find('input').focus().select();
305
-    //still missing a proper scrolling into the selected input
306
-  }
307
-
308
-  if (changed) this.fireChange();
309
-};
310
-
311
-JSONEditor.prototype.haveThingsChanged = function() {
312
-  return (this.json && JSON.stringify(this.json, null, 2) != this.wrapped.get(0).value);
313
-}
314
-
315
-JSONEditor.prototype.saveScrollPosition = function() {
316
-  this.oldScrollHeight = this.builder.scrollTop();
317
-};
318
-
319
-JSONEditor.prototype.recoverScrollPosition = function() {
320
-  this.builder.scrollTop(this.oldScrollHeight);
321
-};
322
-
323
-JSONEditor.prototype.setJsonFromText = function() {
324
-  if (this.wrapped.get(0).value.length == 0) this.wrapped.get(0).value = "{}";
325
-  try {
326
-    this.wrapped.get(0).value = this.wrapped.get(0).value.replace(/((^|[^\\])(\\\\)*)\\n/g, '$1\\\\n').replace(/((^|[^\\])(\\\\)*)\\t/g, '$1\\\\t');
327
-    this.json = JSON.parse(this.wrapped.get(0).value);
328
-  } catch(e) {
329
-    alert("Got bad JSON from text.");
330
-  }
331
-};
332
-
333
-JSONEditor.prototype.checkJsonInText = function() {
334
-  try {
335
-    JSON.parse(this.wrapped.get(0).value);
336
-    return true;
337
-  } catch(e) {
338
-    return false;
339
-  }
340
-};
341
-
342
-JSONEditor.prototype.logJSON = function() {
343
-  console.log(JSON.stringify(this.json, null, 2));
344
-};
345
-
346
-JSONEditor.prototype.cleanBuilder = function() {
347
-  if (!this.builder) {
348
-    this.builder = $('<div class="builder"></div>');
349
-    this.container.append(this.builder);
350
-  }
351
-  this.saveScrollPosition();
352
-  this.builder.text('');
353
-
354
-  this.builder.css("position", "absolute").css("top", 0).css("left", 0);
355
-  this.builder.width(this.wrapped.width()).height(this.wrapped.height());
356
-  this.wrapped.css("position", "absolute").css("top", 0).css("left", 0);
357
-  this.showFunctionButtons("defined");
358
-};
359
-
360
-JSONEditor.prototype.updateStruct = function(struct, key, val, kind, selectionStart, selectionEnd) {
361
-  if(kind == 'key') {
362
-    if (selectionStart && selectionEnd) val = key.substring(0, selectionStart) + val + key.substring(selectionEnd, key.length);
363
-    struct[val] = struct[key];
364
-
365
-    		//order keys
366
-		var orderrest = 0;
367
-		$.each(struct, function (index, value) {
368
-			//re set rest of the keys
369
-			if(orderrest & index != val) {
370
-				var tempval = struct[index];
371
-				delete struct[index];
372
-				struct[index] = tempval;
373
-			}
374
-			if(key == index) {
375
-				orderrest = 1;
376
-			}
377
-		});
378
-		// end of order keys
379
-
380
-    if (key != val) delete struct[key];
381
-  } else {
382
-    if (selectionStart && selectionEnd) val = struct[key].substring(0, selectionStart) + val + struct[key].substring(selectionEnd, struct[key].length);
383
-    struct[key] = val;
384
-  }
385
-};
386
-
387
-JSONEditor.prototype.getValFromStruct = function(struct, key, kind) {
388
-  if(kind == 'key') {
389
-    return key;
390
-  } else {
391
-    return struct[key];
392
-  }
393
-};
394
-
395
-JSONEditor.prototype.doTruncation = function(trueOrFalse) {
396
-  if (this._doTruncation != trueOrFalse) {
397
-    this._doTruncation = trueOrFalse;
398
-    this.rebuild();
399
-  }
400
-};
401
-
402
-JSONEditor.prototype.showWipe = function(trueOrFalse) {
403
-  if (this._showWipe != trueOrFalse) {
404
-    this._showWipe = trueOrFalse;
405
-    this.rebuild();
406
-  }
407
-};
408
-
409
-JSONEditor.prototype.truncate = function(text, length) {
410
-  if (text.length == 0) return '-empty-';
411
-  if(this._doTruncation && text.length > (length || 30)) return(text.substring(0, (length || 30)) + '...');
412
-  return text;
413
-};
414
-
415
-JSONEditor.prototype.replaceLastSelectedFieldIfRecent = function(text) {
416
-  if (this.lastEditingUnfocusedTime > (new Date()).getTime() - 200) { // Short delay for unfocus to occur.
417
-    this.setLastEditingFocus(text);
418
-    this.rebuild();
419
-  }
420
-};
421
-
422
-JSONEditor.prototype.editingUnfocused = function(elem, struct, key, root, kind) {
423
-  var self = this;
424
-
425
-  var selectionStart = elem && elem.target.selectionStart;
426
-  var selectionEnd = elem && elem.target.selectionEnd;
427
-
428
-  this.setLastEditingFocus = function(text) {
429
-    self.updateStruct(struct, key, text, kind, selectionStart, selectionEnd);
430
-    self.json = root; // Because self.json is a new reference due to rebuild.
431
-  };
432
-  this.lastEditingUnfocusedTime = (new Date()).getTime();
433
-};
434
-
435
-JSONEditor.prototype.edit = function(e, key, struct, root, kind){
436
-  var self = this;
437
-  var form = $("<form></form>").css('display', 'inline');
438
-  var input = document.createElement("INPUT");
439
-  input.value = this.getValFromStruct(struct, key, kind);
440
-  //alert(this.getValFromStruct(struct, key, kind));
441
-  input.className = 'edit_field';
442
-  var onblur = function(elem) {
443
-    var val = input.value;
444
-    self.updateStruct(struct, key, val, kind);
445
-    self.editingUnfocused(elem, struct, (kind == 'key' ? val : key), root, kind);
446
-    e.text(self.truncate(val));
447
-    e.get(0).editing = false;
448
-    if (key != val) self.rebuild();
449
-    return false;
450
-  };
451
-  $(input).blur(onblur);
452
-  $(input).keydown(function(e) {
453
-    if (e.keyCode == 9 || e.keyCode == 13) { // Tab and enter
454
-      self.doAutoFocus = true;
455
-      onblur(e);
456
-      return false;
457
-    }
458
-  });
459
-  $(form).submit(function(e) { self.doAutoFocus = true; onblur(e); return false;}).append(input);
460
-  $(e).html(form);
461
-  input.focus();
462
-};
463
-
464
-JSONEditor.prototype.editable = function(text, key, struct, root, kind) {
465
-  var self = this;
466
-  var elem = $('<span class="editable" href="#"></span>').text(this.truncate(text)).click(function(e) {
467
-    if (!this.editing) {
468
-      this.editing = true;
469
-      self.edit($(this), key, struct, root, kind);
470
-    }
471
-    return true;
472
-  });
473
-
474
-  return elem;
475
-}
476
-
477
-JSONEditor.prototype.build = function(json, node, parent, key, root) {
478
-  var elem = null;
479
-  if(json instanceof Array){
480
-    var bq = $(document.createElement("BLOCKQUOTE"));
481
-    bq.append($('<div class="brackets">[</div>'));
482
-
483
-    bq.prepend(this.addUI(json));
484
-    if (parent) {
485
-      if (this._showWipe) bq.prepend(this.wipeUI(key, parent));
486
-    	bq.prepend(this.deleteUI(key, parent));
487
-    }
341
+    };
488 342
 
489
-    for(var i = 0; i < json.length; i++) {
490
-      var innerbq = $(document.createElement("BLOCKQUOTE"));
491
-      var newElem = this.build(json[i], innerbq, json, i, root);
492
-      if (newElem) if (newElem.text() == "??") elem = newElem;
493
-      bq.append(innerbq);
494
-    }
343
+    JSONEditor.prototype.logJSON = function() {
344
+      return console.log(JSON.stringify(this.json, null, 2));
345
+    };
495 346
 
496
-    bq.append($('<div class="brackets">]</div>'));
497
-    node.append(bq);
498
-  } else if (json instanceof Object) {
499
-    var bq = $(document.createElement("BLOCKQUOTE"));
500
-    bq.append($('<div class="bracers">{</div>'));
501
-
502
-    for(var i in json){
503
-      var innerbq = $(document.createElement("BLOCKQUOTE"));
504
-      var newElem = this.editable(i.toString(), i.toString(), json, root, 'key').wrap('<span class="key"></span>').parent();
505
-      innerbq.append(newElem);
506
-      if (newElem) if (newElem.text() == "??") elem = newElem;
507
-      if (typeof json[i] != 'string' && typeof json[i] != 'number') {
508
-        innerbq.prepend(this.braceUI(i, json));
509
-        innerbq.prepend(this.bracketUI(i, json));
510
-        if (this._showWipe) innerbq.prepend(this.wipeUI(i, json));
511
-        innerbq.prepend(this.deleteUI(i, json, true));
347
+    JSONEditor.prototype.cleanBuilder = function() {
348
+      if (!this.builder) {
349
+        this.builder = $('<div class="builder"></div>');
350
+        this.container.append(this.builder);
512 351
       }
513
-      innerbq.append($('<span class="colon">: </span>'));
514
-      newElem = this.build(json[i], innerbq, json, i, root);
515
-      if (newElem) if (newElem.text() == "??") elem = newElem;
516
-      bq.append(innerbq);
517
-    }
352
+      this.saveScrollPosition();
353
+      this.builder.text('');
354
+      return this.showFunctionButtons("defined");
355
+    };
356
+
357
+    JSONEditor.prototype.updateStruct = function(struct, key, val, kind, selectionStart, selectionEnd) {
358
+      var orderrest;
359
+      if (kind === 'key') {
360
+        if (selectionStart && selectionEnd) {
361
+          val = key.substring(0, selectionStart) + val + key.substring(selectionEnd, key.length);
362
+        }
363
+        struct[val] = struct[key];
364
+        orderrest = 0;
365
+        $.each(struct, function(index, value) {
366
+          var tempval;
367
+          if (orderrest & index !== val) {
368
+            tempval = struct[index];
369
+            delete struct[index];
370
+            struct[index] = tempval;
371
+          }
372
+          if (key === index) {
373
+            return orderrest = 1;
374
+          }
375
+        });
376
+        if (key !== val) {
377
+          return delete struct[key];
378
+        }
379
+      } else {
380
+        if (selectionStart && selectionEnd) {
381
+          val = struct[key].substring(0, selectionStart) + val + struct[key].substring(selectionEnd, struct[key].length);
382
+        }
383
+        return struct[key] = val;
384
+      }
385
+    };
518 386
 
519
-    bq.prepend(this.addUI(json));
520
-    if (parent) {
521
-      if (this._showWipe) bq.prepend(this.wipeUI(key, parent));
522
-    	bq.prepend(this.deleteUI(key, parent));
523
-    }
387
+    JSONEditor.prototype.getValFromStruct = function(struct, key, kind) {
388
+      if (kind === 'key') {
389
+        return key;
390
+      } else {
391
+        return struct[key];
392
+      }
393
+    };
524 394
 
525
-    bq.append($('<div class="bracers">}</div>'));
526
-    node.append(bq);
527
-  } else {
528
-    elem = this.editable(json.toString(), key, parent, root, 'value').wrap('<span class="val"></span>').parent();
529
-    node.append(elem);
530
-    node.prepend(this.braceUI(key, parent));
531
-    node.prepend(this.bracketUI(key, parent));
532
-    if (parent) {
533
-      if (this._showWipe) node.prepend(this.wipeUI(key, parent));
534
-    	node.prepend(this.deleteUI(key, parent));
535
-    }
395
+    JSONEditor.prototype.doTruncation = function(trueOrFalse) {
396
+      if (this._doTruncation !== trueOrFalse) {
397
+        this._doTruncation = trueOrFalse;
398
+        return this.rebuild();
399
+      }
400
+    };
401
+
402
+    JSONEditor.prototype.showWipe = function(trueOrFalse) {
403
+      if (this._showWipe !== trueOrFalse) {
404
+        this._showWipe = trueOrFalse;
405
+        return this.rebuild();
406
+      }
407
+    };
408
+
409
+    JSONEditor.prototype.truncate = function(text, length) {
410
+      if (text.length === 0) {
411
+        return '-empty-';
412
+      }
413
+      if (this._doTruncation && text.length > (length || 30)) {
414
+        return text.substring(0, length || 30) + '...';
415
+      }
416
+      return text;
417
+    };
418
+
419
+    JSONEditor.prototype.replaceLastSelectedFieldIfRecent = function(text) {
420
+      if (this.lastEditingUnfocusedTime > (new Date()).getTime() - 200) {
421
+        this.setLastEditingFocus(text);
422
+        return this.rebuild();
423
+      }
424
+    };
425
+
426
+    JSONEditor.prototype.editingUnfocused = function(elem, struct, key, root, kind) {
427
+      var selectionEnd, selectionStart,
428
+        _this = this;
429
+      selectionStart = elem != null ? elem.selectionStart : void 0;
430
+      selectionEnd = elem != null ? elem.selectionEnd : void 0;
431
+      this.setLastEditingFocus = function(text) {
432
+        _this.updateStruct(struct, key, text, kind, selectionStart, selectionEnd);
433
+        return _this.json = root;
434
+      };
435
+      return this.lastEditingUnfocusedTime = (new Date()).getTime();
436
+    };
437
+
438
+    JSONEditor.prototype.edit = function($elem, key, struct, root, kind) {
439
+      var $input, blurHandler, form,
440
+        _this = this;
441
+      form = $("<form></form>").css('display', 'inline');
442
+      $input = $("<input />");
443
+      $input.val(this.getValFromStruct(struct, key, kind));
444
+      $input.addClass('edit_field');
445
+      blurHandler = function() {
446
+        var val, _ref;
447
+        val = $input.val();
448
+        _this.updateStruct(struct, key, val, kind);
449
+        _this.editingUnfocused($elem, struct, (_ref = kind === 'key') != null ? _ref : {
450
+          val: key
451
+        }, root, kind);
452
+        $elem.text(_this.truncate(val));
453
+        $elem.get(0).editing = false;
454
+        if (key !== val) {
455
+          return _this.rebuild();
456
+        }
457
+      };
458
+      $input.blur(blurHandler);
459
+      $input.keydown(function(e) {
460
+        if (e.keyCode === 9 || e.keyCode === 13) {
461
+          _this.doAutoFocus = true;
462
+          return blurHandler();
463
+        }
464
+      });
465
+      $(form).append($input).submit(function(e) {
466
+        e.preventDefault();
467
+        _this.doAutoFocus = true;
468
+        return blurHandler();
469
+      });
470
+      $elem.html(form);
471
+      return $input.focus();
472
+    };
473
+
474
+    JSONEditor.prototype.editable = function(text, key, struct, root, kind) {
475
+      var elem, self;
476
+      self = this;
477
+      elem = $('<span class="editable" href="#"></span>').text(this.truncate(text)).click(function(e) {
478
+        if (!this.editing) {
479
+          this.editing = true;
480
+          self.edit($(this), key, struct, root, kind);
481
+        }
482
+        return true;
483
+      });
484
+      return elem;
485
+    };
486
+
487
+    JSONEditor.prototype.build = function(json, node, parent, key, root) {
488
+      var bq, elem, i, innerbq, jsonkey, jsonvalue, newElem, _i, _ref;
489
+      elem = null;
490
+      if (json instanceof Array) {
491
+        bq = $(document.createElement("BLOCKQUOTE"));
492
+        bq.append($('<div class="brackets">[</div>'));
493
+        bq.prepend(this.addUI(json));
494
+        if (parent) {
495
+          if (this._showWipe) {
496
+            bq.prepend(this.wipeUI(key, parent));
497
+          }
498
+          bq.prepend(this.deleteUI(key, parent));
499
+        }
500
+        for (i = _i = 0, _ref = json.length; 0 <= _ref ? _i < _ref : _i > _ref; i = 0 <= _ref ? ++_i : --_i) {
501
+          innerbq = $(document.createElement("BLOCKQUOTE"));
502
+          newElem = this.build(json[i], innerbq, json, i, root);
503
+          if (newElem && newElem.text() === "??") {
504
+            elem = newElem;
505
+          }
506
+          bq.append(innerbq);
507
+        }
508
+        bq.append($('<div class="brackets">]</div>'));
509
+        node.append(bq);
510
+      } else if (json instanceof Object) {
511
+        bq = $(document.createElement("BLOCKQUOTE"));
512
+        bq.append($('<div class="bracers">{</div>'));
513
+        for (jsonkey in json) {
514
+          jsonvalue = json[jsonkey];
515
+          innerbq = $(document.createElement("BLOCKQUOTE"));
516
+          newElem = this.editable(jsonkey.toString(), jsonkey.toString(), json, root, 'key').wrap('<span class="key"></b>').parent();
517
+          innerbq.append(newElem);
518
+          if (newElem && newElem.text() === "??") {
519
+            elem = newElem;
520
+          }
521
+          if (typeof jsonvalue !== 'string') {
522
+            innerbq.prepend(this.braceUI(jsonkey, json));
523
+            innerbq.prepend(this.bracketUI(jsonkey, json));
524
+            if (this._showWipe) {
525
+              innerbq.prepend(this.wipeUI(jsonkey, json));
526
+            }
527
+            innerbq.prepend(this.deleteUI(jsonkey, json, true));
528
+          }
529
+          innerbq.append($('<span class="colon">: </span>'));
530
+          newElem = this.build(jsonvalue, innerbq, json, jsonkey, root);
531
+          if (newElem && newElem.text() === "??") {
532
+            elem = newElem;
533
+          }
534
+          bq.append(innerbq);
535
+        }
536
+        bq.prepend(this.addUI(json));
537
+        if (parent) {
538
+          if (this._showWipe) {
539
+            bq.prepend(this.wipeUI(key, parent));
540
+          }
541
+          bq.prepend(this.deleteUI(key, parent));
542
+        }
543
+        bq.append($('<div class="bracers">}</div>'));
544
+        node.append(bq);
545
+      } else {
546
+        elem = this.editable(json.toString(), key, parent, root, 'value').wrap('<span class="val"></span>').parent();
547
+        node.append(elem);
548
+        node.prepend(this.braceUI(key, parent));
549
+        node.prepend(this.bracketUI(key, parent));
550
+        if (parent) {
551
+          if (this._showWipe) {
552
+            node.prepend(this.wipeUI(key, parent));
553
+          }
554
+          node.prepend(this.deleteUI(key, parent));
555
+        }
556
+      }
557
+      return elem;
558
+    };
559
+
560
+    return JSONEditor;
561
+
562
+  })();
536 563
 
537
-  }
538
-  return elem;
539
-};
564
+}).call(this);

+ 37 - 0
vendor/assets/stylesheets/jquery.json-editor.css

@@ -0,0 +1,37 @@
1
+.json-editor {
2
+  background-color: #FFF;
3
+  position: relative; }
4
+  .json-editor textarea {
5
+    width: 100%;
6
+    font-family: monospace; }
7
+  .json-editor .builder {
8
+    background-color: white;
9
+    overflow: auto;
10
+    font-size: 0.9em; }
11
+    .json-editor .builder .key {
12
+      font-weight: bold; }
13
+      .json-editor .builder .key .edit_field {
14
+        width: 150px; }
15
+    .json-editor .builder .val .edit_field {
16
+      width: 200px; }
17
+  .json-editor blockquote {
18
+    margin: 0;
19
+    padding: 0;
20
+    clear: both;
21
+    padding-left: 7px; }
22
+  .json-editor div {
23
+    background-color: #cfc;
24
+    margin: 1px;
25
+    padding: 2px; }
26
+  .json-editor .val {
27
+    font-style: italic; }
28
+  .json-editor .key a, .json-editor .val a {
29
+    color: black;
30
+    text-decoration: none; }
31
+  .json-editor .icon {
32
+    display: block;
33
+    float: right;
34
+    text-decoration: none;
35
+    padding-left: 5px;
36
+    border: 0;
37
+    color: blue; }

+ 0 - 63
vendor/assets/stylesheets/jquery.json-editor.css.scss

@@ -1,63 +0,0 @@
1
-.json-editor {
2
-  background-color: #FFF;
3
-  position: relative;
4
-
5
-  .builder {
6
-    background-color: white;
7
-    overflow: auto;
8
-    font-size: 0.9em;
9
-    padding-right: 10px;
10
-
11
-    .key {
12
-      font-weight: bold;
13
-
14
-      .edit_field {
15
-        width: 80px;
16
-      }
17
-
18
-      a {
19
-        color: black;
20
-        text-decoration: none;
21
-      }
22
-    }
23
-
24
-    .val {
25
-      font-style: italic;
26
-
27
-      .edit_field {
28
-        width: 180px;
29
-      }
30
-
31
-      a {
32
-        color: black;
33
-        text-decoration: none;
34
-      }
35
-    }
36
-  }
37
-
38
-  blockquote {
39
-    margin: 0;
40
-    padding: 0;
41
-    clear: both;
42
-    padding-left: 7px;
43
-  }
44
-
45
-  div {
46
-    background-color: #cfc;
47
-    margin: 1px;
48
-    padding: 2px;
49
-  }
50
-
51
-  .icon {
52
-    display: block;
53
-    float: right;
54
-    text-decoration: none;
55
-    padding: 0 5px;
56
-    border: 0 !important;
57
-    color: blue;
58
-
59
-    &:hover {
60
-      background-color: #bbb;
61
-    }
62
-  }
63
-}